feat: remove start.sh script and add appearance preferences management

- Deleted the start.sh script for container management.
- Added AGENTS.md for project guidelines and development principles.
- Introduced new SQL migration files for user appearance preferences and platform settings.
- Implemented appearance provider to manage user interface themes and preferences.
- Created branding utility to define and manage branding-related constants and types.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-27 22:12:16 -04:00
parent b582b6c88e
commit fbeca7cfee
39 changed files with 3388 additions and 977 deletions
+42 -35
View File
@@ -1,43 +1,50 @@
# Base application env # Copy this file to .env before running Docker Compose:
NODE_ENV="development" # cp .env.example .env
PORT="3000"
HOSTNAME="0.0.0.0" # Runtime
NODE_ENV=production
# Auth # Auth
# You can generate a new secret on the command line with: # Generate with: openssl rand -base64 32
# openssl rand -base64 32 AUTH_SECRET=change-me-generate-a-real-secret
AUTH_SECRET="your-auth-secret" BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_URL="http://localhost:3000" # Set to your production URL in production
# App URL # Public app URL
# Used for client-side redirects and base URLs NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# Database (Postgres) # Postgres used by docker-compose.yml
# These are required for Docker container initialization POSTGRES_USER=postgres
POSTGRES_USER="postgres" POSTGRES_PASSWORD=postgres
POSTGRES_PASSWORD="postgres" POSTGRES_DB=postgres
POSTGRES_DB="postgres" DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
DB_DISABLE_SSL=true
# Connect string for the app # White-label defaults used at image build time.
DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres" # Admin-managed platform branding in the app can override these after setup.
# Disable SSL for Docker local Postgres; set to false or remove for managed Postgres NEXT_PUBLIC_BRAND_NAME="beenvoice"
DB_DISABLE_SSL="true" NEXT_PUBLIC_BRAND_TAGLINE="Simple and efficient invoicing for freelancers and small businesses"
NEXT_PUBLIC_BRAND_LOGO_TEXT="beenvoice"
NEXT_PUBLIC_BRAND_ICON="$"
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME="beenvoice"
NEXT_PUBLIC_DEFAULT_FONT="brand"
NEXT_PUBLIC_DEFAULT_BODY_FONT="brand"
NEXT_PUBLIC_DEFAULT_HEADING_FONT="brand"
NEXT_PUBLIC_DEFAULT_RADIUS="xl"
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE="floating"
# Email (Resend). Replace with real keys in production # Email delivery via Resend (optional)
RESEND_API_KEY="your-resend-api-key" # Leave blank to disable invoice/password-reset email delivery.
RESEND_DOMAIN="" RESEND_API_KEY=
RESEND_DOMAIN=
# Analytics # Analytics via Umami (optional)
NEXT_PUBLIC_UMAMI_WEBSITE_ID="your-website-id-here" # Leave website ID blank to disable analytics.
NEXT_PUBLIC_UMAMI_SCRIPT_URL="https://analytics.umami.is/script.js" NEXT_PUBLIC_UMAMI_WEBSITE_ID=
# Build tweaks NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# SKIP_ENV_VALIDATION=1
# SSO / Authentik (Optional - only needed if using SSO authentication) # SSO via Authentik OIDC (optional)
# Configure these if you want to enable Single Sign-On with Authentik OIDC NEXT_PUBLIC_AUTHENTIK_ENABLED=false
# The issuer should be your Authentik application's OAuth2 provider URL AUTHENTIK_ISSUER=
# Example: https://auth.example.com/application/o/your-app-slug AUTHENTIK_CLIENT_ID=
AUTHENTIK_ISSUER="" AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_CLIENT_ID="" AUTHENTIK_ORIGIN=
AUTHENTIK_CLIENT_SECRET=""
+2 -1
View File
@@ -34,6 +34,7 @@ yarn-error.log*
# local env files # local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env .env
.env.prod
.env*.local .env*.local
.env*.production .env*.production
@@ -41,4 +42,4 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
# idea files # idea files
.idea .idea
View File
+57 -47
View File
@@ -1,59 +1,69 @@
FROM oven/bun:1.2.19 as deps # syntax=docker/dockerfile:1
WORKDIR /app FROM oven/bun:1 AS base
WORKDIR /usr/src/app
# Install dependencies (only package manifests copied first for better caching) FROM base AS install
COPY package.json bun.lock ./ RUN mkdir -p /temp/dev
# Install minimal toolchain for native devDependencies (e.g., better-sqlite3) during build COPY package.json bun.lock /temp/dev/
# Minimal toolchain (kept for safety, but we skip dev deps) RUN cd /temp/dev && bun install --frozen-lockfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& ln -sf /usr/bin/python3 /usr/bin/python \
&& rm -rf /var/lib/apt/lists/*
# Install all deps (including dev) for build tooling like @tailwindcss/postcss
RUN bun install --frozen-lockfile --verbose
FROM oven/bun:1.2.19 as builder RUN mkdir -p /temp/prod
WORKDIR /app COPY package.json bun.lock /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
FROM base AS build
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID=
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
ARG NEXT_PUBLIC_AUTHENTIK_ENABLED=false
ARG NEXT_PUBLIC_BRAND_NAME=beenvoice
ARG NEXT_PUBLIC_BRAND_TAGLINE="Simple and efficient invoicing for freelancers and small businesses"
ARG NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
ARG NEXT_PUBLIC_BRAND_ICON=$
ARG NEXT_PUBLIC_DEFAULT_INTERFACE_THEME=beenvoice
ARG NEXT_PUBLIC_DEFAULT_FONT=brand
ARG NEXT_PUBLIC_DEFAULT_BODY_FONT=brand
ARG NEXT_PUBLIC_DEFAULT_HEADING_FONT=brand
ARG NEXT_PUBLIC_DEFAULT_RADIUS=xl
ARG NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE=floating
ENV NODE_ENV=production ENV NODE_ENV=production
ENV SKIP_ENV_VALIDATION=1 ENV SKIP_ENV_VALIDATION=1
ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_OPTIONS=--max-old-space-size=4096
ENV BETTER_AUTH_URL=http://localhost:3000
COPY --from=deps /app/node_modules ./node_modules ENV AUTH_SECRET=docker-build-placeholder-secret-do-not-use
COPY . . ENV DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
# Build Next.js app (no memory constraints) ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_AUTHENTIK_ENABLED=$NEXT_PUBLIC_AUTHENTIK_ENABLED
ENV NEXT_PUBLIC_BRAND_NAME=$NEXT_PUBLIC_BRAND_NAME
ENV NEXT_PUBLIC_BRAND_TAGLINE=$NEXT_PUBLIC_BRAND_TAGLINE
ENV NEXT_PUBLIC_BRAND_LOGO_TEXT=$NEXT_PUBLIC_BRAND_LOGO_TEXT
ENV NEXT_PUBLIC_BRAND_ICON=$NEXT_PUBLIC_BRAND_ICON
ENV NEXT_PUBLIC_DEFAULT_INTERFACE_THEME=$NEXT_PUBLIC_DEFAULT_INTERFACE_THEME
ENV NEXT_PUBLIC_DEFAULT_FONT=$NEXT_PUBLIC_DEFAULT_FONT
ENV NEXT_PUBLIC_DEFAULT_BODY_FONT=$NEXT_PUBLIC_DEFAULT_BODY_FONT
ENV NEXT_PUBLIC_DEFAULT_HEADING_FONT=$NEXT_PUBLIC_DEFAULT_HEADING_FONT
ENV NEXT_PUBLIC_DEFAULT_RADIUS=$NEXT_PUBLIC_DEFAULT_RADIUS
ENV NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE=$NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE
RUN bun run build RUN bun run build
FROM oven/bun:1.2.19 as runner FROM base AS release
WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000
# Create non-root user and group COPY --from=install /temp/prod/node_modules node_modules
RUN addgroup --system --gid 1001 beenvoice \ COPY --from=build /usr/src/app/.next ./.next
&& adduser --system --uid 1001 --ingroup beenvoice beenvoice COPY --from=build /usr/src/app/public ./public
COPY --from=build /usr/src/app/package.json ./package.json
COPY --from=build /usr/src/app/src/server/db/migrate.ts ./src/server/db/migrate.ts
COPY --from=build /usr/src/app/drizzle ./drizzle
# Copy runtime artifacts and install production deps RUN chown -R bun:bun /usr/src/app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/bun.lock ./bun.lock
RUN bun install --frozen-lockfile --production --verbose
COPY --from=builder /app/start.sh ./start.sh
COPY --from=builder /app/next.config.js ./next.config.js
COPY --from=builder /app/src ./src
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/.env.example ./.env.example
RUN chmod +x ./start.sh
USER 1001
USER bun
EXPOSE 3000 EXPOSE 3000
CMD ["bun", "run", "start", "-p", "3000", "-H", "0.0.0.0"]
CMD ["./start.sh"]
+294 -304
View File
File diff suppressed because it is too large Load Diff
+52 -7
View File
@@ -1,21 +1,66 @@
services: services:
app:
build:
context: .
args:
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
NEXT_PUBLIC_BRAND_NAME: ${NEXT_PUBLIC_BRAND_NAME:-beenvoice}
NEXT_PUBLIC_BRAND_TAGLINE: ${NEXT_PUBLIC_BRAND_TAGLINE:-Simple and efficient invoicing for freelancers and small businesses}
NEXT_PUBLIC_BRAND_LOGO_TEXT: ${NEXT_PUBLIC_BRAND_LOGO_TEXT:-beenvoice}
NEXT_PUBLIC_BRAND_ICON: ${NEXT_PUBLIC_BRAND_ICON:-$$}
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: ${NEXT_PUBLIC_DEFAULT_INTERFACE_THEME:-beenvoice}
NEXT_PUBLIC_DEFAULT_FONT: ${NEXT_PUBLIC_DEFAULT_FONT:-brand}
NEXT_PUBLIC_DEFAULT_BODY_FONT: ${NEXT_PUBLIC_DEFAULT_BODY_FONT:-brand}
NEXT_PUBLIC_DEFAULT_HEADING_FONT: ${NEXT_PUBLIC_DEFAULT_HEADING_FONT:-brand}
NEXT_PUBLIC_DEFAULT_RADIUS: ${NEXT_PUBLIC_DEFAULT_RADIUS:-xl}
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: ${NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE:-floating}
image: beenvoice:local
environment:
NODE_ENV: production
AUTH_SECRET: ${AUTH_SECRET:?Set AUTH_SECRET in .env}
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
DB_DISABLE_SSL: "true"
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_DOMAIN: ${RESEND_DOMAIN:-}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.umami.is/script.js}
NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED:-false}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
command:
- sh
- -c
- bun src/server/db/migrate.ts && bun run start -p 3000 -H 0.0.0.0
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db: db:
image: postgres:17-alpine image: postgres:17-alpine
container_name: beenvoice-db environment:
env_file: POSTGRES_USER: ${POSTGRES_USER:-postgres}
- .env.local POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-postgres}
volumes: volumes:
- beenvoice_pg_data:/var/lib/postgresql/data - beenvoice_pg_data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
restart: unless-stopped
volumes: volumes:
beenvoice_pg_data: beenvoice_pg_data:
driver: local
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_user" ADD COLUMN "interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL;
ALTER TABLE "beenvoice_user" ADD COLUMN "fontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_user"
ADD COLUMN "bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_user"
ADD COLUMN "sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL;
@@ -0,0 +1,59 @@
ALTER TABLE "beenvoice_user"
ADD COLUMN "role" varchar(20) DEFAULT 'user' NOT NULL;
--> statement-breakpoint
UPDATE "beenvoice_user"
SET "role" = 'admin'
WHERE "id" = (
SELECT "id"
FROM "beenvoice_user"
ORDER BY "createdAt" ASC
LIMIT 1
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "beenvoice_platform_setting" (
"id" varchar(50) PRIMARY KEY DEFAULT 'global' NOT NULL,
"brandName" varchar(100) DEFAULT 'beenvoice' NOT NULL,
"brandTagline" varchar(255) DEFAULT 'Simple and efficient invoicing for freelancers and small businesses' NOT NULL,
"brandLogoText" varchar(100) DEFAULT 'beenvoice' NOT NULL,
"brandIcon" varchar(20) DEFAULT '$' NOT NULL,
"colorTheme" varchar(50) DEFAULT 'slate' NOT NULL,
"customColor" varchar(50),
"theme" varchar(20) DEFAULT 'system' NOT NULL,
"interfaceTheme" varchar(50) DEFAULT 'beenvoice' NOT NULL,
"bodyFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
"headingFontPreference" varchar(50) DEFAULT 'brand' NOT NULL,
"radiusPreference" varchar(20) DEFAULT 'xl' NOT NULL,
"sidebarStyle" varchar(20) DEFAULT 'floating' NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
INSERT INTO "beenvoice_platform_setting" (
"id",
"brandName",
"brandTagline",
"brandLogoText",
"brandIcon",
"colorTheme",
"customColor",
"theme",
"interfaceTheme",
"bodyFontPreference",
"headingFontPreference",
"radiusPreference",
"sidebarStyle"
) VALUES (
'global',
'beenvoice',
'Simple and efficient invoicing for freelancers and small businesses',
'beenvoice',
'$',
'slate',
NULL,
'system',
'beenvoice',
'brand',
'brand',
'xl',
'floating'
) ON CONFLICT ("id") DO NOTHING;
+14
View File
@@ -0,0 +1,14 @@
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfTemplate" varchar(20) DEFAULT 'classic' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfAccentColor" varchar(50) DEFAULT '#111827' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfFooterText" varchar(120) DEFAULT 'Professional Invoicing' NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfShowLogo" boolean DEFAULT true NOT NULL;
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting"
ADD COLUMN "pdfShowPageNumbers" boolean DEFAULT true NOT NULL;
+31 -3
View File
@@ -15,13 +15,41 @@
"when": 1775356013998, "when": 1775356013998,
"tag": "0001_supreme_the_enforcers", "tag": "0001_supreme_the_enforcers",
"breakpoints": true "breakpoints": true
} },
,{ {
"idx": 2, "idx": 2,
"version": "7", "version": "7",
"when": 1775400000000, "when": 1775400000000,
"tag": "0002_tax_deductible", "tag": "0002_tax_deductible",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1775600000000,
"tag": "0003_appearance_preferences",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1777336000000,
"tag": "0004_platform_appearance_controls",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1777337000000,
"tag": "0005_platform_settings_and_roles",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1777338000000,
"tag": "0006_pdf_generation_settings",
"breakpoints": true
} }
] ]
} }
+4 -4
View File
@@ -7,12 +7,12 @@
"build": "next build", "build": "next build",
"check": "eslint . && tsc --noEmit", "check": "eslint . && tsc --noEmit",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "bun src/server/db/migrate.ts", "db:migrate": "bun drizzle-kit migrate",
"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 up -d",
"docker:down": "docker-compose down && colima stop", "docker:down": "docker compose down && colima stop",
"deploy": "drizzle-kit push && next build", "deploy": "drizzle-kit push && next build",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
@@ -64,6 +64,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
@@ -92,7 +93,6 @@
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.6", "baseline-browser-mapping": "^2.9.6",
"dotenv": "^17.2.3",
"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.0.10",
+14
View File
@@ -55,6 +55,20 @@ export async function POST(request: NextRequest) {
}) })
.where(eq(users.id, user.id)); .where(eq(users.id, user.id));
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return NextResponse.json(
{
success: true,
message:
"If an account with that email exists, password reset instructions have been sent.",
},
{ status: 200 },
);
}
// Send password reset email using Resend // Send password reset email using Resend
try { try {
const resend = new Resend(env.RESEND_API_KEY); const resend = new Resend(env.RESEND_API_KEY);
+47 -34
View File
@@ -10,6 +10,7 @@ import { Label } from "~/components/ui/label";
import { toast } from "sonner"; import { toast } from "sonner";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal"; import { LegalModal } from "~/components/ui/legal-modal";
import { env } from "~/env";
import { import {
Mail, Mail,
Lock, Lock,
@@ -21,6 +22,7 @@ import {
} from "lucide-react"; } from "lucide-react";
function SignInForm() { function SignInForm() {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
@@ -63,24 +65,27 @@ function SignInForm() {
} }
return ( return (
<div className="flex min-h-screen items-center justify-center relative overflow-hidden"> <div className="relative flex min-h-screen items-center justify-center overflow-hidden">
{/* Blob Background */} {/* Blob Background */}
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center"> <div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div> <div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="w-[800px] h-[800px] bg-neutral-400/30 dark:bg-neutral-500/20 rounded-full blur-3xl animate-blob"></div> <div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div> </div>
<Card className="mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:border md:shadow-2xl md:bg-background/80 md:backdrop-blur-xl md:border-border/50 md:rounded-3xl"> <Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
<CardContent className="grid h-full p-0 md:grid-cols-2"> <CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */} {/* Hero Section - Hidden on mobile */}
<div className="bg-primary/5 relative hidden md:flex md:flex-col md:justify-center md:p-12 border-r border-border/50"> <div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8"> <div className="space-y-8">
<div className="space-y-4"> <div className="space-y-4">
<Logo size="xl" /> <Logo size="xl" />
<div className="space-y-3"> <div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl font-heading"> <h1 className="font-heading text-3xl font-bold lg:text-4xl">
Welcome back to your Welcome back to your
<span className="text-primary italic"> invoicing workspace</span> <span className="text-primary italic">
{" "}
invoicing workspace
</span>
</h1> </h1>
<p className="text-muted-foreground text-lg"> <p className="text-muted-foreground text-lg">
Continue managing your clients and creating professional Continue managing your clients and creating professional
@@ -95,7 +100,9 @@ function SignInForm() {
<Users className="text-primary h-5 w-5" /> <Users className="text-primary h-5 w-5" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h3 className="font-semibold text-foreground">Client Management</h3> <h3 className="text-foreground font-semibold">
Client Management
</h3>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Organize and track all your clients in one place Organize and track all your clients in one place
</p> </p>
@@ -107,7 +114,9 @@ function SignInForm() {
<FileText className="text-primary h-5 w-5" /> <FileText className="text-primary h-5 w-5" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h3 className="font-semibold text-foreground">Professional Invoices</h3> <h3 className="text-foreground font-semibold">
Professional Invoices
</h3>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Beautiful templates that get you paid faster Beautiful templates that get you paid faster
</p> </p>
@@ -119,7 +128,9 @@ function SignInForm() {
<TrendingUp className="text-primary h-5 w-5" /> <TrendingUp className="text-primary h-5 w-5" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h3 className="font-semibold text-foreground">Payment Tracking</h3> <h3 className="text-foreground font-semibold">
Payment Tracking
</h3>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Monitor your income with real-time insights Monitor your income with real-time insights
</p> </p>
@@ -138,35 +149,37 @@ function SignInForm() {
</div> </div>
<div className="space-y-2 text-center md:text-left"> <div className="space-y-2 text-center md:text-left">
<h1 className="text-3xl font-bold font-heading">Sign In</h1> <h1 className="font-heading text-3xl font-bold">Sign In</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Enter your credentials to access your account Enter your credentials to access your account
</p> </p>
</div> </div>
<div className="space-y-4"> {authentikEnabled && (
<Button <div className="space-y-4">
variant="outline" <Button
type="button" variant="outline"
className="w-full h-11 relative rounded-xl" type="button"
onClick={handleSocialSignIn} className="relative h-11 w-full rounded-xl"
disabled={loading} onClick={handleSocialSignIn}
> disabled={loading}
<Shield className="mr-2 h-4 w-4" /> >
Sign in with Authentik <Shield className="mr-2 h-4 w-4" />
</Button> Sign in with Authentik
</Button>
<div className="relative"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/50" /> <span className="border-border/50 w-full border-t" />
</div> </div>
<div className="relative flex justify-center text-xs uppercase"> <div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground"> <span className="bg-background text-muted-foreground px-2">
Or continue with Or continue with
</span> </span>
</div>
</div> </div>
</div> </div>
</div> )}
<form onSubmit={handleSignIn} className="space-y-4"> <form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -180,7 +193,7 @@ function SignInForm() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoFocus autoFocus
className="h-11 pl-10 bg-background/50 border-border/60 focus:bg-background transition-all" className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="m@example.com" placeholder="m@example.com"
/> />
</div> </div>
@@ -204,7 +217,7 @@ function SignInForm() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
className="h-11 pl-10 bg-background/50 border-border/60 focus:bg-background transition-all" className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="Enter your password" placeholder="Enter your password"
/> />
</div> </div>
@@ -212,7 +225,7 @@ function SignInForm() {
<Button <Button
type="submit" type="submit"
className="h-11 w-full rounded-xl text-base shadow-lg shadow-primary/20 hover:shadow-primary/30" className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
@@ -30,15 +30,15 @@ export function InvoiceDetailsSkeleton() {
<Skeleton className="h-8 w-48" /> <Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-24 rounded-full" /> <Skeleton className="h-6 w-24 rounded-full" />
</div> </div>
<div className="space-y-1 sm:space-y-0 text-sm"> <div className="space-y-1 text-sm sm:space-y-0">
<div className="flex gap-2"> <div className="flex gap-2">
<Skeleton className="h-4 w-32" /> <Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-32 hidden sm:block" /> <Skeleton className="hidden h-4 w-32 sm:block" />
</div> </div>
</div> </div>
</div> </div>
<div className="flex-shrink-0 text-left sm:text-right"> <div className="flex-shrink-0 text-left sm:text-right">
<Skeleton className="h-4 w-24 mb-1 sm:ml-auto" /> <Skeleton className="mb-1 h-4 w-24 sm:ml-auto" />
<Skeleton className="h-9 w-32 sm:ml-auto" /> <Skeleton className="h-9 w-32 sm:ml-auto" />
</div> </div>
</div> </div>
@@ -118,7 +118,7 @@ export function InvoiceDetailsSkeleton() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Skeleton className="h-5 w-3/4 mb-2" /> <Skeleton className="mb-2 h-5 w-3/4" />
<div className="flex gap-4"> <div className="flex gap-4">
<Skeleton className="h-4 w-24" /> <Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" /> <Skeleton className="h-4 w-16" />
@@ -156,7 +156,7 @@ export function InvoiceDetailsSkeleton() {
{/* Right Column - Actions */} {/* Right Column - Actions */}
<div className="space-y-6"> <div className="space-y-6">
<Card className="sticky top-20"> <Card className="lg:sticky lg:top-6">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded-full" /> <Skeleton className="h-5 w-5 rounded-full" />
@@ -25,6 +25,9 @@ export function PDFDownloadButton({
{ id: invoiceId }, { id: invoiceId },
{ enabled: false }, { enabled: false },
); );
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, {
staleTime: 60_000,
});
const handleDownloadPDF = async () => { const handleDownloadPDF = async () => {
if (isGenerating) return; if (isGenerating) return;
@@ -55,7 +58,13 @@ export function PDFDownloadButton({
items: invoiceData.items, items: invoiceData.items,
}; };
await generateInvoicePDF(pdfData); await generateInvoicePDF(pdfData, {
pdfTemplate: platformTheme?.pdfTemplate,
pdfAccentColor: platformTheme?.pdfAccentColor,
pdfFooterText: platformTheme?.pdfFooterText,
pdfShowLogo: platformTheme?.pdfShowLogo,
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers,
});
toast.success("PDF downloaded successfully"); toast.success("PDF downloaded successfully");
} catch (error) { } catch (error) {
console.error("PDF generation error:", error); console.error("PDF generation error:", error);
+1 -1
View File
@@ -411,7 +411,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{/* Right Column - Actions */} {/* Right Column - Actions */}
<div className="space-y-6"> <div className="space-y-6">
<Card className="sticky top-20"> <Card className="lg:sticky lg:top-6">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Check className="h-5 w-5" /> <Check className="h-5 w-5" />
+8 -3
View File
@@ -25,6 +25,11 @@ import {
} from "recharts"; } from "recharts";
import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react"; import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react";
function toNumericChartValue(value: unknown) {
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
return Number.isFinite(numericValue) ? numericValue : 0;
}
export default function ReportsPage() { export default function ReportsPage() {
const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery(); const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery();
const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery(); const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery();
@@ -259,7 +264,7 @@ export default function ReportsPage() {
<CartesianGrid strokeDasharray="3 3" className="stroke-border" /> <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} /> <XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> <YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} /> <Tooltip formatter={(value) => [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} /> <Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -281,7 +286,7 @@ export default function ReportsPage() {
<BarChart data={overviewData.topClients} layout="vertical"> <BarChart data={overviewData.topClients} layout="vertical">
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> <XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} /> <YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} /> <Tooltip formatter={(value) => [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} /> <Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -433,7 +438,7 @@ export default function ReportsPage() {
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} /> <XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> <YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip <Tooltip
formatter={(v: number, name: string) => [formatCurrency(v), name === "income" ? "Income" : "Expenses"]} formatter={(value, name) => [formatCurrency(toNumericChartValue(value)), name === "income" ? "Income" : "Expenses"]}
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }}
/> />
<Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} /> <Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} />
File diff suppressed because it is too large Load Diff
+63 -13
View File
@@ -6,26 +6,34 @@ import { Inter, Playfair_Display, Geist_Mono } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react"; import { TRPCReactProvider } from "~/trpc/react";
import { Toaster } from "~/components/ui/sonner"; import { Toaster } from "~/components/ui/sonner";
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider"; import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
import { AppearanceProvider } from "~/components/providers/appearance-provider";
import {
brand,
defaultBodyFontPreference,
defaultFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
} from "~/lib/branding";
import { UmamiScript } from "~/components/analytics/umami-script"; import { UmamiScript } from "~/components/analytics/umami-script";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "beenvoice - Invoicing Made Simple", title: `${brand.name} - Invoicing Made Simple`,
description: description: brand.tagline,
"Simple and efficient invoicing for freelancers and small businesses",
icons: [{ rel: "icon", url: "/favicon.ico" }], icons: [{ rel: "icon", url: "/favicon.ico" }],
}; };
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-sans", variable: "--font-inter",
display: "swap", display: "swap",
}); });
const playfair = Playfair_Display({ const playfair = Playfair_Display({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-heading", variable: "--font-playfair",
display: "swap", display: "swap",
}); });
@@ -42,20 +50,62 @@ export default function RootLayout({
<html <html
suppressHydrationWarning suppressHydrationWarning
lang="en" lang="en"
data-interface-theme={defaultInterfaceTheme}
data-font={defaultFontPreference}
data-body-font={defaultBodyFontPreference}
data-heading-font={defaultHeadingFontPreference}
data-radius={defaultRadiusPreference}
data-sidebar-style={defaultSidebarStyle}
data-color-mode="system"
data-color-theme="slate"
className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`} className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`}
> >
<head>
<script
id="appearance-init"
dangerouslySetInnerHTML={{
__html: `
try {
var defaults = {
interfaceTheme: "${defaultInterfaceTheme}",
fontPreference: "${defaultFontPreference}",
bodyFontPreference: "${defaultBodyFontPreference}",
headingFontPreference: "${defaultHeadingFontPreference}",
radiusPreference: "${defaultRadiusPreference}",
sidebarStyle: "${defaultSidebarStyle}",
colorMode: "system",
colorTheme: "slate"
};
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
var appearance = Object.assign(defaults, stored);
var root = document.documentElement;
root.dataset.interfaceTheme = appearance.interfaceTheme;
root.dataset.font = appearance.fontPreference;
root.dataset.bodyFont = appearance.bodyFontPreference || appearance.fontPreference;
root.dataset.headingFont = appearance.headingFontPreference || appearance.fontPreference;
root.dataset.radius = appearance.radiusPreference;
root.dataset.sidebarStyle = appearance.sidebarStyle;
root.dataset.colorMode = appearance.colorMode;
root.dataset.colorTheme = appearance.colorTheme;
if (appearance.colorMode === "dark") root.classList.add("dark");
if (appearance.customColor) root.style.setProperty("--custom-primary", appearance.customColor);
} catch {}
`,
}}
/>
</head>
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased"> <body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center"> <div className="brand-background pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div> <div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="w-[800px] h-[800px] bg-neutral-400/40 dark:bg-neutral-500/30 rounded-full blur-3xl animate-blob"></div> <div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30"></div>
</div> </div>
<TRPCReactProvider> <TRPCReactProvider>
<AnimationPreferencesProvider> <AppearanceProvider>
<div className="relative z-10"> <AnimationPreferencesProvider>
{children} <div className="relative z-10">{children}</div>
</div> </AnimationPreferencesProvider>
</AnimationPreferencesProvider> </AppearanceProvider>
<Toaster /> <Toaster />
<UmamiScript /> <UmamiScript />
</TRPCReactProvider> </TRPCReactProvider>
+85 -49
View File
@@ -12,20 +12,21 @@ import {
BarChart3, BarChart3,
Rocket, Rocket,
} from "lucide-react"; } from "lucide-react";
import { brand } from "~/lib/branding";
export default function HomePage() { export default function HomePage() {
return ( return (
<div className="min-h-screen relative overflow-x-hidden"> <div className="relative min-h-screen overflow-x-hidden">
<AuthRedirect /> <AuthRedirect />
{/* Blob Background for Homepage */} {/* Blob Background for Homepage */}
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center"> <div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div> <div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="w-[800px] h-[800px] bg-neutral-400/30 dark:bg-neutral-500/20 rounded-full blur-3xl animate-blob"></div> <div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="fixed top-4 left-4 right-4 z-50 m-4 rounded-2xl border border-border/60 bg-background/80 backdrop-blur-md"> <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="mx-auto px-6">
<div className="flex h-16 items-center justify-between"> <div className="flex h-16 items-center justify-between">
<Logo /> <Logo />
@@ -67,25 +68,25 @@ export default function HomePage() {
<section className="relative pt-48 pb-32"> <section className="relative pt-48 pb-32">
<div className="container mx-auto px-4 text-center"> <div className="container mx-auto px-4 text-center">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 border px-4 py-1 text-sm rounded-full"> <Badge className="bg-primary/10 text-primary border-primary/20 mb-8 rounded-full border px-4 py-1 text-sm">
<Zap className="mr-2 h-3.5 w-3.5" /> <Zap className="mr-2 h-3.5 w-3.5" />
Completely Free for Everyone Completely Free for Everyone
</Badge> </Badge>
<h1 className="text-foreground mb-8 text-6xl font-heading font-bold tracking-tight sm:text-7xl lg:text-8xl leading-tight"> <h1 className="text-foreground font-heading mb-8 text-6xl leading-tight font-bold tracking-tight sm:text-7xl lg:text-8xl">
Invoicing Made <br /> {brand.name} <br />
<span className="text-primary italic">Beautifully Simple.</span> <span className="text-primary italic">Beautifully Simple.</span>
</h1> </h1>
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl text-xl leading-relaxed font-sans"> <p className="text-muted-foreground mx-auto mb-12 max-w-2xl font-sans text-xl leading-relaxed">
Create professional invoices, manage clients, and track payments with a tool that feels as good as it looks. {brand.tagline}
</p> </p>
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center"> <div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
<Link href="/auth/register"> <Link href="/auth/register">
<Button <Button
size="lg" size="lg"
className="h-14 px-10 text-lg rounded-2xl shadow-xl shadow-primary/20 hover:shadow-2xl hover:shadow-primary/30 transition-all duration-300" 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 Start For Free
<ArrowRight className="ml-2 h-5 w-5" /> <ArrowRight className="ml-2 h-5 w-5" />
@@ -95,14 +96,14 @@ export default function HomePage() {
<Button <Button
variant="outline" variant="outline"
size="lg" size="lg"
className="h-14 px-10 text-lg rounded-2xl border-border/50 bg-background/50 hover:bg-background/80 backdrop-blur-sm" className="border-border/50 bg-background/50 hover:bg-background/80 h-14 rounded-2xl px-10 text-lg backdrop-blur-sm"
> >
Learn More Learn More
</Button> </Button>
</a> </a>
</div> </div>
<div className="mt-16 text-muted-foreground/80 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8"> <div className="text-muted-foreground/80 mt-16 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Check className="text-primary h-4 w-4" /> <Check className="text-primary h-4 w-4" />
<span>No credit card required</span> <span>No credit card required</span>
@@ -121,11 +122,12 @@ export default function HomePage() {
</section> </section>
{/* Features Section */} {/* Features Section */}
<section id="features" className="py-24 relative"> <section id="features" className="relative py-24">
<div className="container mx-auto px-4 relative z-10"> <div className="relative z-10 container mx-auto px-4">
<div className="mb-20 text-center"> <div className="mb-20 text-center">
<h2 className="text-foreground mb-6 text-4xl font-heading font-bold sm:text-5xl"> <h2 className="text-foreground font-heading mb-6 text-4xl font-bold sm:text-5xl">
Everything you need to <span className="italic text-primary">thrive</span> Everything you need to{" "}
<span className="text-primary italic">thrive</span>
</h2> </h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-lg"> <p className="text-muted-foreground mx-auto max-w-2xl text-lg">
Powerful features wrapped in a calm, focused interface. Powerful features wrapped in a calm, focused interface.
@@ -137,28 +139,46 @@ export default function HomePage() {
{ {
icon: Rocket, icon: Rocket,
title: "Quick Setup", title: "Quick Setup",
description: "Start creating invoices immediately. No complicated setup required.", description:
items: ["Simple client management", "Professional templates", "Easy invoice sending"] "Start creating invoices immediately. No complicated setup required.",
items: [
"Simple client management",
"Professional templates",
"Easy invoice sending",
],
}, },
{ {
icon: BarChart3, icon: BarChart3,
title: "Payment Tracking", title: "Payment Tracking",
description: "Keep track of invoice status and monitor your payments effortlessly.", description:
items: ["Invoice status tracking", "Payment history", "Overdue notifications"] "Keep track of invoice status and monitor your payments effortlessly.",
items: [
"Invoice status tracking",
"Payment history",
"Overdue notifications",
],
}, },
{ {
icon: Shield, icon: Shield,
title: "Professional Features", title: "Professional Features",
description: "Tools that make you look professional and get you paid faster.", description:
items: ["PDF generation", "Custom tax rates", "Professional numbering"] "Tools that make you look professional and get you paid faster.",
} items: [
"PDF generation",
"Custom tax rates",
"Professional numbering",
],
},
].map((feature, i) => ( ].map((feature, i) => (
<Card key={i} className="group hover:-translate-y-2 transition-transform duration-500 border-border/40 bg-background/60 backdrop-blur-xl"> <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"> <CardContent className="p-8">
<div className="bg-primary/10 text-primary mb-6 inline-flex rounded-2xl p-4"> <div className="bg-primary/10 text-primary mb-6 inline-flex rounded-2xl p-4">
<feature.icon className="h-8 w-8" /> <feature.icon className="h-8 w-8" />
</div> </div>
<h3 className="text-foreground mb-4 text-2xl font-bold font-heading"> <h3 className="text-foreground font-heading mb-4 text-2xl font-bold">
{feature.title} {feature.title}
</h3> </h3>
<p className="text-muted-foreground mb-6 leading-relaxed"> <p className="text-muted-foreground mb-6 leading-relaxed">
@@ -166,8 +186,11 @@ export default function HomePage() {
</p> </p>
<ul className="space-y-3"> <ul className="space-y-3">
{feature.items.map((item, j) => ( {feature.items.map((item, j) => (
<li key={j} className="flex items-center gap-3 text-sm text-foreground/80"> <li
<div className="h-1.5 w-1.5 rounded-full bg-primary" /> 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} {item}
</li> </li>
))} ))}
@@ -180,39 +203,45 @@ export default function HomePage() {
</section> </section>
{/* Pricing Section */} {/* Pricing Section */}
<section id="pricing" className="py-24 relative overflow-hidden"> <section id="pricing" className="relative overflow-hidden py-24">
<div className="container mx-auto px-4 relative z-10"> <div className="relative z-10 container mx-auto px-4">
<div className="max-w-4xl mx-auto text-center mb-16"> <div className="mx-auto mb-16 max-w-4xl text-center">
<h2 className="text-5xl font-heading font-bold mb-6">Simple Pricing</h2> <h2 className="font-heading mb-6 text-5xl font-bold">
<p className="text-xl text-muted-foreground">Focus on your work, not on fees.</p> Simple Pricing
</h2>
<p className="text-muted-foreground text-xl">
Focus on your work, not on fees.
</p>
</div> </div>
<div className="max-w-md mx-auto"> <div className="mx-auto max-w-md">
<Card className="relative overflow-visible border-primary/50 shadow-2xl shadow-primary/5 bg-background/80 backdrop-blur-xl"> <Card className="border-primary/50 shadow-primary/5 bg-background/80 relative overflow-visible shadow-2xl backdrop-blur-xl">
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground px-6 py-1.5 rounded-full text-sm font-medium shadow-lg"> <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 Forever Free
</div> </div>
<CardContent className="p-10 text-center"> <CardContent className="p-10 text-center">
<div className="mb-2 text-6xl font-bold font-heading">$0</div> <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="text-muted-foreground mb-8">
No credit card required.
</div>
<div className="space-y-4 mb-10 text-left pl-8"> <div className="mb-10 space-y-4 pl-8 text-left">
{[ {[
"Unlimited Invoices", "Unlimited Invoices",
"Unlimited Clients", "Unlimited Clients",
"PDF Downloads", "PDF Downloads",
"Payment Tracking", "Payment Tracking",
"Email Support" "Email Support",
].map((item, i) => ( ].map((item, i) => (
<div key={i} className="flex items-center gap-3"> <div key={i} className="flex items-center gap-3">
<Check className="h-5 w-5 text-primary shrink-0" /> <Check className="text-primary h-5 w-5 shrink-0" />
<span className="text-foreground/90">{item}</span> <span className="text-foreground/90">{item}</span>
</div> </div>
))} ))}
</div> </div>
<Link href="/auth/register" className="block"> <Link href="/auth/register" className="block">
<Button size="lg" className="w-full text-lg h-12 rounded-xl"> <Button size="lg" className="h-12 w-full rounded-xl text-lg">
Get Started Get Started
</Button> </Button>
</Link> </Link>
@@ -223,20 +252,27 @@ export default function HomePage() {
</section> </section>
{/* Footer */} {/* Footer */}
<footer className="border-t border-border/40 bg-background/50 backdrop-blur-sm py-12 mt-12"> <footer className="border-border/40 bg-background/50 mt-12 border-t py-12 backdrop-blur-sm">
<div className="container mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-6"> <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"> <div className="flex items-center gap-3">
<Logo size="sm" /> <Logo size="sm" />
<span className="text-sm text-muted-foreground">© 2024 beenvoice</span> <span className="text-muted-foreground text-sm">
© 2024 beenvoice
</span>
</div> </div>
<div className="flex gap-8 text-sm text-muted-foreground"> <div className="text-muted-foreground flex gap-8 text-sm">
<a href="#" className="hover:text-foreground transition-colors">Privacy</a> <a href="#" className="hover:text-foreground transition-colors">
<a href="#" className="hover:text-foreground transition-colors">Terms</a> Privacy
<a href="#" className="hover:text-foreground transition-colors">Contact</a> </a>
<a href="#" className="hover:text-foreground transition-colors">
Terms
</a>
<a href="#" className="hover:text-foreground transition-colors">
Contact
</a>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
); );
} }
+42 -9
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { brand } from "~/lib/branding";
import { useAppearance } from "~/components/providers/appearance-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface LogoProps { interface LogoProps {
@@ -10,6 +12,9 @@ interface LogoProps {
} }
export function Logo({ className, size = "md", animated = true }: LogoProps) { export function Logo({ className, size = "md", animated = true }: LogoProps) {
const appearance = useAppearance();
const logoText = appearance.brandLogoText || brand.logoText;
const icon = appearance.brandIcon || brand.icon;
const sizeClasses = { const sizeClasses = {
sm: "text-base", sm: "text-base",
md: "text-xl", md: "text-xl",
@@ -19,7 +24,15 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
}; };
if (!animated) { if (!animated) {
return <LogoContent className={className} size={size} sizeClasses={sizeClasses} />; return (
<LogoContent
className={className}
size={size}
sizeClasses={sizeClasses}
logoText={logoText}
icon={icon}
/>
);
} }
return ( return (
@@ -27,7 +40,11 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.1, ease: "easeOut" }} transition={{ duration: 0.1, ease: "easeOut" }}
className={cn("flex items-center font-mono", sizeClasses[size], className)} className={cn(
"flex items-center font-mono",
sizeClasses[size],
className,
)}
> >
<motion.span <motion.span
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -35,7 +52,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
className="text-primary font-bold tracking-tight" className="text-primary font-bold tracking-tight"
> >
$ {icon}
</motion.span> </motion.span>
{size !== "icon" && ( {size !== "icon" && (
<> <>
@@ -51,7 +68,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"
> >
been {logoText.slice(0, Math.ceil(logoText.length / 2))}
</motion.span> </motion.span>
<motion.span <motion.span
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -59,7 +76,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"
> >
voice {logoText.slice(Math.ceil(logoText.length / 2))}
</motion.span> </motion.span>
</> </>
)} )}
@@ -71,19 +88,35 @@ function LogoContent({
className, className,
size, size,
sizeClasses, sizeClasses,
logoText,
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;
icon: string;
}) { }) {
return ( return (
<div className={cn("flex items-center font-mono", sizeClasses[size], className)}> <div
<span className="text-primary font-bold tracking-tight">$</span> className={cn(
"flex items-center font-mono",
sizeClasses[size],
className,
)}
>
<span className="text-primary font-bold tracking-tight">
{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">been</span> <span className="text-foreground font-bold tracking-tight">
<span className="text-foreground/70 font-bold tracking-tight">voice</span> {logoText.slice(0, Math.ceil(logoText.length / 2))}
</span>
<span className="text-foreground/70 font-bold tracking-tight">
{logoText.slice(Math.ceil(logoText.length / 2))}
</span>
</> </>
)} )}
</div> </div>
+84 -7
View File
@@ -20,6 +20,7 @@ import { NumberInput } from "~/components/ui/number-input";
import { PageHeader } from "~/components/layout/page-header"; import { PageHeader } from "~/components/layout/page-header";
import { InvoiceLineItems } from "./invoice-line-items"; import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view"; import { InvoiceCalendarView } from "./invoice-calendar-view";
import { EmailPreview } from "./email-preview";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -30,6 +31,7 @@ import {
List, List,
FileText, FileText,
ChevronDown, ChevronDown,
Mail,
} from "lucide-react"; } from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency"; import { SUPPORTED_CURRENCIES } from "~/lib/currency";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
@@ -58,7 +60,7 @@ interface InvoiceFormProps {
function InvoiceFormSkeleton() { function InvoiceFormSkeleton() {
return ( return (
<div className="space-y-6 pb-32"> <div className="space-y-6 pb-8">
<PageHeader <PageHeader
title="Loading..." title="Loading..."
description="Loading invoice form" description="Loading invoice form"
@@ -199,6 +201,16 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
return { subtotal, taxAmount, total }; return { subtotal, taxAmount, total };
}, [formData.items, formData.taxRate]); }, [formData.items, formData.taxRate]);
const selectedClient = React.useMemo(
() => clients?.find((client) => client.id === formData.clientId),
[clients, formData.clientId],
);
const selectedBusiness = React.useMemo(
() =>
businesses?.find((business) => business.id === formData.businessId) ??
businesses?.find((business) => business.isDefault),
[businesses, formData.businessId],
);
// Handlers (addItem, updateItem etc. - same as before) // Handlers (addItem, updateItem etc. - same as before)
const addItem = (date?: unknown) => { const addItem = (date?: unknown) => {
@@ -370,7 +382,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return ( return (
<> <>
<div className="page-enter space-y-6 pb-32"> <div className="page-enter space-y-6 pb-8">
<PageHeader <PageHeader
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"} title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
description="Manage your invoice" description="Manage your invoice"
@@ -393,7 +405,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}> <Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
{/* TAB SELECTOR: w-full, p-1, visible background */} {/* TAB SELECTOR: w-full, p-1, visible background */}
<TabsList className="bg-muted grid h-auto w-full grid-cols-3 rounded-xl p-1"> <TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1">
<TabsTrigger <TabsTrigger
value="details" value="details"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm" className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
@@ -412,6 +424,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
> >
Timesheet Timesheet
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="preview"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Preview
</TabsTrigger>
</TabsList> </TabsList>
{/* DETAILS TAB */} {/* DETAILS TAB */}
@@ -419,7 +437,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
value="details" value="details"
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2" className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
> >
<Card className="h-fit"> <Card className="h-full">
<CardHeader> <CardHeader>
<CardTitle className="flex gap-2 text-base"> <CardTitle className="flex gap-2 text-base">
<User className="h-4 w-4" /> Client Details <User className="h-4 w-4" /> Client Details
@@ -495,10 +513,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardContent> </CardContent>
</Card> </Card>
<Card className="h-fit"> <Card className="h-full">
<CardHeader> <CardHeader>
<CardTitle className="flex gap-2 text-base"> <CardTitle className="flex gap-2 text-base">
<Tag className="h-4 w-4" /> Invoice Config <Tag className="h-4 w-4" /> Invoice Settings
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -524,7 +542,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-[96px_1fr] sm:gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Prefix</Label> <Label>Prefix</Label>
<Input <Input
@@ -536,6 +554,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
className="w-full" className="w-full"
/> />
</div> </div>
<div className="space-y-2">
<Label>Invoice Number</Label>
<Input
value={formData.invoiceNumber}
onChange={(e) =>
updateField("invoiceNumber", e.target.value)
}
placeholder="INV-20260428-000001"
className="w-full font-mono"
/>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -717,6 +746,54 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent
value="preview"
className="mt-6 focus-visible:outline-none"
>
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<Mail className="h-5 w-5" /> Email Preview
</CardTitle>
</CardHeader>
<CardContent>
<EmailPreview
subject={`Invoice ${formData.invoiceNumber} from ${
selectedBusiness?.name ?? "Your Business"
}`}
fromEmail={selectedBusiness?.email ?? ""}
toEmail={selectedClient?.email ?? ""}
content=""
invoice={{
invoiceNumber: formData.invoiceNumber,
issueDate: formData.issueDate,
dueDate: formData.dueDate,
taxRate: formData.taxRate,
status: formData.status,
totalAmount: totals.total,
client: selectedClient
? {
name: selectedClient.name,
email: selectedClient.email,
}
: undefined,
business: selectedBusiness
? {
name: selectedBusiness.name,
email: selectedBusiness.email,
}
: undefined,
items: formData.items.map((item) => ({
id: item.id,
hours: item.hours,
rate: item.rate,
})),
}}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs> </Tabs>
</div> </div>
+64 -80
View File
@@ -49,79 +49,59 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"bg-card group hover:border-primary/20 hidden rounded-xl border p-4 shadow-sm transition-all md:block", "group hover:bg-muted/40 hidden min-h-16 grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] items-center gap-2 border-b px-3 py-2 transition-colors md:grid",
)} )}
> >
<div className="flex items-center gap-3"> <DatePicker
{/* Main Content */} date={item.date}
<div className="flex-1 space-y-3"> onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
{/* Description */} size="sm"
<div> className="w-full"
<Input inputClassName="h-9"
value={item.description} />
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="w-full text-sm font-medium"
/>
</div>
{/* Controls Row */} <Input
<div className="flex flex-wrap items-center gap-3"> value={item.description}
{/* Date */} onChange={(e) => onUpdate(index, "description", e.target.value)}
<DatePicker placeholder="Describe the work performed..."
date={item.date} className="h-9 w-full text-sm font-medium"
onDateChange={(date) => />
onUpdate(index, "date", date ?? new Date())
}
size="sm"
className="w-full sm:w-[180px]"
inputClassName="h-9"
/>
{/* Hours */} <NumberInput
<NumberInput value={item.hours}
value={item.hours} onChange={(value) => onUpdate(index, "hours", value)}
onChange={(value) => onUpdate(index, "hours", value)} min={0}
min={0} step={0.25}
step={0.25} width="full"
width="auto" className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
className="h-9 min-w-[100px] flex-1 font-mono" suffix="h"
suffix="h" />
/>
{/* Rate */} <NumberInput
<NumberInput value={item.rate}
value={item.rate} onChange={(value) => onUpdate(index, "rate", value)}
onChange={(value) => onUpdate(index, "rate", value)} min={0}
min={0} step={1}
step={1} prefix="$"
prefix="$" width="full"
width="auto" className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
className="h-9 min-w-[100px] flex-1 font-mono" />
/>
{/* Amount */} <div className="text-primary text-right font-mono font-semibold">
<div className="ml-auto"> ${(item.hours * item.rate).toFixed(2)}
<span className="text-primary font-semibold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
{/* Actions */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div> </div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
); );
}, },
@@ -240,7 +220,15 @@ export function InvoiceLineItems({
return ( return (
<div className={cn("space-y-2", className)}> <div className={cn("space-y-2", className)}>
<AnimatePresence> <AnimatePresence>
<div className="space-y-2"> <div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] gap-2 border-b px-3 py-2 text-xs font-medium md:grid">
<span>Date</span>
<span>Description</span>
<span className="text-right">Hours</span>
<span className="text-right">Rate</span>
<span className="text-right">Amount</span>
<span />
</div>
{items.map((item, index) => ( {items.map((item, index) => (
<React.Fragment key={item.id}> <React.Fragment key={item.id}>
{/* Desktop/Tablet Card */} {/* Desktop/Tablet Card */}
@@ -275,19 +263,15 @@ export function InvoiceLineItems({
</AnimatePresence> </AnimatePresence>
{/* Add Item Button */} {/* Add Item Button */}
<div className="px-3 pt-3"> <Button
<div className="border-t pt-6"> type="button"
<Button variant="outline"
type="button" onClick={onAddItem}
variant="outline" className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-3 w-full border-dashed py-6 transition-all"
onClick={onAddItem} >
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 w-full border-dashed py-8 transition-all" <Plus className="mr-2 h-4 w-4" />
> Add Line Item
<Plus className="mr-2 h-4 w-4" /> </Button>
Add Line Item
</Button>
</div>
</div>
</div> </div>
); );
} }
+10 -7
View File
@@ -8,9 +8,11 @@ import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { useAppearance } from "~/components/providers/appearance-provider";
function DashboardContent({ children }: { children: React.ReactNode }) { function DashboardContent({ children }: { children: React.ReactNode }) {
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
const [isMobileOpen, setIsMobileOpen] = React.useState(false); const [isMobileOpen, setIsMobileOpen] = React.useState(false);
return ( return (
@@ -21,7 +23,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
</div> </div>
{/* Mobile Sidebar (Sheet) */} {/* Mobile Sidebar (Sheet) */}
<div className="md:hidden fixed top-0 left-0 right-0 h-16 bg-background/80 backdrop-blur-md border-b z-50 px-4 flex items-center"> <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">
<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="h-10 w-10 bg-background shadow-sm" suppressHydrationWarning>
@@ -47,13 +49,14 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
suppressHydrationWarning suppressHydrationWarning
className={cn( className={cn(
"flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out", "flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out",
// Desktop margins based on collapsed state
"md:ml-0", "md:ml-0",
// Sidebar is fixed at left: 1rem (16px), width: 16rem (256px) or 4rem (64px) sidebarStyle === "floating"
// We need margin-left = left + width + gap ? isCollapsed
// Expanded: 16px + 256px + 16px (gap) = 288px (18rem) ? "md:ml-24"
// Collapsed: 16px + 64px + 16px (gap) = 96px (6rem) : "md:ml-[18rem]"
isCollapsed ? "md:ml-24" : "md:ml-[18rem]" : isCollapsed
? "md:ml-16"
: "md:ml-64",
)} )}
> >
<div className="p-4 pt-16 md:pt-4"> <div className="p-4 pt-16 md:pt-4">
+13 -47
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import { useAppearance } from "~/components/providers/appearance-provider";
import { useSidebar } from "~/components/layout/sidebar-provider";
interface FloatingActionBarProps { interface FloatingActionBarProps {
/** Content to display on the left side */ /** Content to display on the left side */
@@ -13,74 +15,38 @@ interface FloatingActionBarProps {
className?: string; className?: string;
} }
import { useSidebar } from "~/components/layout/sidebar-provider";
export function FloatingActionBar({ export function FloatingActionBar({
leftContent, leftContent,
children, children,
className, className,
}: FloatingActionBarProps) { }: FloatingActionBarProps) {
const [isDocked, setIsDocked] = useState(false);
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
useEffect(() => {
const handleScroll = () => {
// Check if we're truly at the bottom of the page
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
const clientHeight = document.documentElement.clientHeight;
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
// Only dock when we're within 50px of the actual bottom AND there's content to scroll
const hasScrollableContent = scrollHeight > clientHeight;
const shouldDock = hasScrollableContent && distanceFromBottom <= 50;
// If content is too small, keep it at bottom of viewport
const contentTooSmall = scrollHeight <= clientHeight + 200;
setIsDocked(shouldDock && !contentTooSmall);
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll(); // Check initial state
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return ( return (
<div <div
className={cn( className={cn(
// Base positioning - always at bottom "pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out",
"fixed right-0 z-50 transition-all duration-300 ease-in-out", sidebarStyle === "floating"
// Safe area and sidebar adjustments ? isCollapsed
"pb-safe-area-inset-bottom left-0", ? "md:left-24"
isCollapsed ? "md:left-24" : "md:left-[18rem]", : "md:left-[18rem]"
// Conditional centering based on dock state : isCollapsed
isDocked ? "flex justify-center" : "", ? "md:left-16"
// Dynamic bottom positioning : "md:left-64",
isDocked ? "bottom-4" : "bottom-0",
// Add entrance animation
"animate-slide-in-bottom", "animate-slide-in-bottom",
className, className,
)} )}
> >
{/* Content container - full width when floating, content width when docked */} <div className="w-full px-4 transition-transform duration-300">
<div
className={cn(
"w-full transition-transform duration-300",
isDocked ? "mx-auto mb-0 px-4" : "mb-4 px-4",
)}
>
<Card className="hover-lift bg-card border-border border shadow-lg"> <Card className="hover-lift bg-card border-border border shadow-lg">
<CardContent className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4"> <CardContent className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4">
{/* Left content */}
{leftContent && ( {leftContent && (
<div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3"> <div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3">
{leftContent} {leftContent}
</div> </div>
)} )}
{/* Right actions */}
<div className="animate-fade-in animate-delay-100 flex items-center gap-2 sm:gap-3"> <div className="animate-fade-in animate-delay-100 flex items-center gap-2 sm:gap-3">
{children} {children}
</div> </div>
+3 -3
View File
@@ -42,9 +42,9 @@ 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="rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden relative"> <div className="platform-header-surface rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden relative">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent pointer-events-none" /> <div className="platform-header-gradient absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent pointer-events-none" />
<div className="p-6 relative"> <div className="platform-header-content p-6 relative">
<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 sm:flex-row sm:items-start sm:justify-between gap-4">
+7 -3
View File
@@ -25,6 +25,7 @@ import {
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { getGravatarUrl } from "~/lib/gravatar"; import { getGravatarUrl } from "~/lib/gravatar";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { useAppearance } from "~/components/providers/appearance-provider";
interface SidebarProps { interface SidebarProps {
mobile?: boolean; mobile?: boolean;
@@ -36,6 +37,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
// const session = { user: null } as any; const isPending = false; // const session = { user: null } as any; const isPending = false;
const { isCollapsed, toggleCollapse } = useSidebar(); const { isCollapsed, toggleCollapse } = useSidebar();
const { sidebarStyle } = useAppearance();
// If mobile, always expanded // If mobile, always expanded
const collapsed = mobile ? false : isCollapsed; const collapsed = mobile ? false : isCollapsed;
@@ -214,9 +216,11 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
return ( return (
<aside <aside
className={cn( className={cn(
"fixed top-4 bottom-4 left-4 z-30 hidden md:flex flex-col", "fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
"bg-background/80 backdrop-blur-xl border-border/50 border shadow-xl rounded-3xl transition-all duration-300 ease-in-out", sidebarStyle === "floating"
isCollapsed ? "w-16" : "w-64" ? "top-4 bottom-4 left-4 border-border/50 rounded-3xl border bg-background/80 shadow-xl backdrop-blur-xl"
: "top-0 bottom-0 left-0 rounded-none border-r border-border bg-background shadow-none",
isCollapsed ? "w-16" : "w-64",
)} )}
> >
{SidebarContent} {SidebarContent}
@@ -0,0 +1,384 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
defaultFontPreference,
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
brand as defaultBrand,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
import { api } from "~/trpc/react";
type AppearancePreferences = {
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: "classic" | "minimal";
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
type AppearancePatch = Partial<AppearancePreferences>;
type ServerAppearance = {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
theme: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: "classic" | "minimal";
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void;
isUpdating: boolean;
};
const STORAGE_KEY = "bv.appearance";
const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme,
fontPreference: defaultFontPreference,
bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference,
radiusPreference: defaultRadiusPreference,
sidebarStyle: defaultSidebarStyle,
colorMode: "system",
colorTheme: "slate",
brandName: defaultBrand.name,
brandTagline: defaultBrand.tagline,
brandLogoText: defaultBrand.logoText,
brandIcon: defaultBrand.icon,
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
};
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
function getServerAppearancePatch(
serverAppearance: ServerAppearance,
): AppearancePatch {
return {
interfaceTheme: serverAppearance.interfaceTheme,
fontPreference: serverAppearance.fontPreference,
bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference,
sidebarStyle: serverAppearance.sidebarStyle,
colorMode: serverAppearance.theme,
colorTheme: serverAppearance.colorTheme,
customColor: serverAppearance.customColor,
brandName: serverAppearance.brandName,
brandTagline: serverAppearance.brandTagline,
brandLogoText: serverAppearance.brandLogoText,
brandIcon: serverAppearance.brandIcon,
pdfTemplate: serverAppearance.pdfTemplate,
pdfAccentColor: serverAppearance.pdfAccentColor,
pdfFooterText: serverAppearance.pdfFooterText,
pdfShowLogo: serverAppearance.pdfShowLogo,
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
};
}
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 {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
? parsed.interfaceTheme
: undefined,
fontPreference: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
? parsed.bodyFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
headingFontPreference: isFontPreference(parsed.headingFontPreference)
? parsed.headingFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
radiusPreference: isRadiusPreference(parsed.radiusPreference)
? parsed.radiusPreference
: undefined,
sidebarStyle: isSidebarStyle(parsed.sidebarStyle)
? parsed.sidebarStyle
: undefined,
colorMode: isColorMode(parsed.colorMode) ? parsed.colorMode : undefined,
colorTheme: isColorTheme(parsed.colorTheme)
? parsed.colorTheme
: undefined,
customColor:
typeof parsed.customColor === "string" ? parsed.customColor : undefined,
brandName:
typeof parsed.brandName === "string" ? parsed.brandName : undefined,
brandTagline:
typeof parsed.brandTagline === "string"
? parsed.brandTagline
: undefined,
brandLogoText:
typeof parsed.brandLogoText === "string"
? parsed.brandLogoText
: undefined,
brandIcon:
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
pdfTemplate:
parsed.pdfTemplate === "classic" || parsed.pdfTemplate === "minimal"
? parsed.pdfTemplate
: undefined,
pdfAccentColor:
typeof parsed.pdfAccentColor === "string"
? parsed.pdfAccentColor
: undefined,
pdfFooterText:
typeof parsed.pdfFooterText === "string"
? parsed.pdfFooterText
: undefined,
pdfShowLogo:
typeof parsed.pdfShowLogo === "boolean"
? parsed.pdfShowLogo
: undefined,
pdfShowPageNumbers:
typeof parsed.pdfShowPageNumbers === "boolean"
? parsed.pdfShowPageNumbers
: undefined,
};
} catch {
return null;
}
}
function writeStoredAppearance(prefs: AppearancePreferences) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// Storage can be unavailable in private browsing or locked-down contexts.
}
}
function applyAppearance(prefs: AppearancePreferences) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.dataset.interfaceTheme = prefs.interfaceTheme;
root.dataset.font = prefs.fontPreference;
root.dataset.bodyFont = prefs.bodyFontPreference;
root.dataset.headingFont = prefs.headingFontPreference;
root.dataset.radius = prefs.radiusPreference;
root.dataset.sidebarStyle = prefs.sidebarStyle;
root.dataset.colorMode = prefs.colorMode;
root.dataset.colorTheme = prefs.colorTheme;
root.classList.toggle("dark", prefs.colorMode === "dark");
if (prefs.customColor) {
root.style.setProperty("--custom-primary", prefs.customColor);
} else {
root.style.removeProperty("--custom-primary");
}
}
export function AppearanceProvider({
children,
}: {
children: React.ReactNode;
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => {
await utils.settings.getTheme.invalidate();
},
onError: () => {
const cachedAppearance = utils.settings.getTheme.getData();
const fallback = cachedAppearance
? {
...defaultAppearance,
...getServerAppearancePatch(cachedAppearance),
}
: defaultAppearance;
setAppearance(fallback);
applyAppearance(fallback);
writeStoredAppearance(fallback);
},
});
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
useEffect(() => {
const storedAppearance = readStoredAppearance();
if (!storedAppearance) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
}, []);
useEffect(() => {
if (!serverAppearance) return;
const next = getServerAppearancePatch(serverAppearance);
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...next }));
}, [serverAppearance]);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
updateMutation.mutate({
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],
);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
updateAppearance,
isUpdating: updateMutation.isPending,
}),
[appearance, updateAppearance, updateMutation.isPending],
);
return (
<AppearanceContext.Provider value={value}>
{children}
</AppearanceContext.Provider>
);
}
export function useAppearance() {
const ctx = useContext(AppearanceContext);
if (!ctx) {
throw new Error("useAppearance must be used within an AppearanceProvider");
}
return ctx;
}
+38 -4
View File
@@ -13,10 +13,7 @@ export const env = createEnv({
: z.string().optional(), : z.string().optional(),
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
BETTER_AUTH_URL: z.string().url().optional(), BETTER_AUTH_URL: z.string().url().optional(),
RESEND_API_KEY: RESEND_API_KEY: z.string().min(1).optional(),
process.env.NODE_ENV === "production"
? z.string().min(1)
: z.string().min(1).optional(),
RESEND_DOMAIN: z.string().optional(), RESEND_DOMAIN: z.string().optional(),
NODE_ENV: z NODE_ENV: z
.enum(["development", "test", "production"]) .enum(["development", "test", "production"])
@@ -26,6 +23,7 @@ export const env = createEnv({
AUTHENTIK_ISSUER: z.string().url().optional(), AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(), AUTHENTIK_CLIENT_ID: z.string().optional(),
AUTHENTIK_CLIENT_SECRET: z.string().optional(), AUTHENTIK_CLIENT_SECRET: z.string().optional(),
AUTHENTIK_ORIGIN: z.string().url().optional(),
}, },
/** /**
@@ -37,6 +35,27 @@ export const env = createEnv({
NEXT_PUBLIC_APP_URL: z.string().url().optional(), NEXT_PUBLIC_APP_URL: z.string().url().optional(),
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(), NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().optional(), NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().optional(),
NEXT_PUBLIC_AUTHENTIK_ENABLED: z.coerce.boolean().optional(),
NEXT_PUBLIC_BRAND_NAME: z.string().optional(),
NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(),
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
NEXT_PUBLIC_BRAND_ICON: z.string().optional(),
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: z
.enum(["beenvoice", "shadcn", "minimal", "editorial"])
.optional(),
NEXT_PUBLIC_DEFAULT_FONT: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_BODY_FONT: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_HEADING_FONT: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_RADIUS: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z
.enum(["floating", "docked"])
.optional(),
}, },
/** /**
@@ -54,9 +73,24 @@ export const env = createEnv({
AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER, AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER,
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET, AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
NEXT_PUBLIC_AUTHENTIK_ENABLED: process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED,
NEXT_PUBLIC_BRAND_NAME: process.env.NEXT_PUBLIC_BRAND_NAME,
NEXT_PUBLIC_BRAND_TAGLINE: process.env.NEXT_PUBLIC_BRAND_TAGLINE,
NEXT_PUBLIC_BRAND_LOGO_TEXT: process.env.NEXT_PUBLIC_BRAND_LOGO_TEXT,
NEXT_PUBLIC_BRAND_ICON: process.env.NEXT_PUBLIC_BRAND_ICON,
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME:
process.env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME,
NEXT_PUBLIC_DEFAULT_FONT: process.env.NEXT_PUBLIC_DEFAULT_FONT,
NEXT_PUBLIC_DEFAULT_BODY_FONT: process.env.NEXT_PUBLIC_DEFAULT_BODY_FONT,
NEXT_PUBLIC_DEFAULT_HEADING_FONT:
process.env.NEXT_PUBLIC_DEFAULT_HEADING_FONT,
NEXT_PUBLIC_DEFAULT_RADIUS: process.env.NEXT_PUBLIC_DEFAULT_RADIUS,
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE:
process.env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE,
}, },
/** /**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
+53 -45
View File
@@ -5,55 +5,63 @@ import { genericOAuth } from "better-auth/plugins";
import { db } from "~/server/db"; import { db } from "~/server/db";
import * as schema from "~/server/db/schema"; import * as schema from "~/server/db/schema";
const authentikEnabled = Boolean(
process.env.AUTHENTIK_ISSUER &&
process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET,
);
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
schema: { schema: {
user: schema.users, user: schema.users,
session: schema.sessions, session: schema.sessions,
account: schema.accounts, account: schema.accounts,
verification: schema.verificationTokens, verification: schema.verificationTokens,
ssoProvider: schema.ssoProviders, ssoProvider: schema.ssoProviders,
}, },
}), }),
trustedOrigins: [ trustedOrigins: [
"https://beenvoice.soconnor.dev", "https://beenvoice.soconnor.dev",
"https://auth.soconnor.dev", // Authentik IdP for OIDC discovery ...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
], ],
...(authentikEnabled && {
accountLinking: { accountLinking: {
enabled: true, enabled: true,
trustedProviders: ["authentik"], trustedProviders: ["authentik"],
}, },
emailAndPassword: { }),
enabled: true, emailAndPassword: {
password: { enabled: true,
hash: async (password) => { password: {
const bcrypt = await import("bcryptjs"); hash: async (password) => {
return bcrypt.hash(password, 12); const bcrypt = await import("bcryptjs");
}, return bcrypt.hash(password, 12);
verify: async ({ hash, password }) => { },
const bcrypt = await import("bcryptjs"); verify: async ({ hash, password }) => {
return bcrypt.compare(password, hash); const bcrypt = await import("bcryptjs");
}, return bcrypt.compare(password, hash);
}, },
}, },
plugins: [ },
nextCookies(), plugins: [
genericOAuth({ nextCookies(),
...(authentikEnabled
? [
genericOAuth({
config: [ config: [
{ {
providerId: "authentik", providerId: "authentik",
clientId: process.env.AUTHENTIK_CLIENT_ID!, clientId: process.env.AUTHENTIK_CLIENT_ID!,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!, clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`, discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
// Explicit endpoints to ensure correct routing in production scopes: ["openid", "email", "profile"],
authorizationUrl: "https://auth.soconnor.dev/application/o/authorize/", pkce: true,
tokenUrl: "https://auth.soconnor.dev/application/o/token/", },
userInfoUrl: "https://auth.soconnor.dev/application/o/userinfo/",
scopes: ["openid", "email", "profile"],
pkce: true,
},
], ],
}), }),
], ]
: []),
],
}); });
+249
View File
@@ -0,0 +1,249 @@
import { env } from "~/env";
export type InterfaceTheme = "beenvoice" | "shadcn" | "minimal" | "editorial";
export type FontPreference = "brand" | "platform" | "inter" | "serif";
export type RadiusPreference = "none" | "sm" | "md" | "lg" | "xl";
export type SidebarStyle = "floating" | "docked";
export type ColorMode = "light" | "dark" | "system";
export type ColorTheme =
| "slate"
| "blue"
| "green"
| "rose"
| "orange"
| "custom";
export const interfaceThemes: {
value: InterfaceTheme;
label: string;
description: string;
}[] = [
{
value: "beenvoice",
label: "beenvoice",
description: "Opinionated brand system with expressive headings.",
},
{
value: "shadcn",
label: "shadcn/ui",
description: "A plain shadcn baseline for white-label starts.",
},
{
value: "minimal",
label: "Minimal",
description: "Quiet surfaces, lower contrast, and restrained chrome.",
},
{
value: "editorial",
label: "Editorial",
description: "A warmer presentation style for service-led brands.",
},
];
export const themePresets: Record<
InterfaceTheme,
{
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
colorTheme: ColorTheme;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
}
> = {
beenvoice: {
interfaceTheme: "beenvoice",
bodyFontPreference: "brand",
headingFontPreference: "brand",
colorTheme: "slate",
radiusPreference: "xl",
sidebarStyle: "floating",
},
shadcn: {
interfaceTheme: "shadcn",
bodyFontPreference: "inter",
headingFontPreference: "inter",
colorTheme: "slate",
radiusPreference: "md",
sidebarStyle: "docked",
},
minimal: {
interfaceTheme: "minimal",
bodyFontPreference: "platform",
headingFontPreference: "platform",
colorTheme: "slate",
radiusPreference: "sm",
sidebarStyle: "docked",
},
editorial: {
interfaceTheme: "editorial",
bodyFontPreference: "platform",
headingFontPreference: "serif",
colorTheme: "rose",
radiusPreference: "lg",
sidebarStyle: "floating",
},
};
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: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Brand Sans",
description: "Inter body text for a clean product feel.",
},
{
value: "platform",
label: "Platform",
description: "Native system body text for the current OS.",
},
{
value: "inter",
label: "Inter",
description: "Inter body text, explicitly selected.",
},
{
value: "serif",
label: "Serif",
description: "Georgia-style body text for editorial deployments.",
},
];
export const headingFontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Brand Serif",
description: "Playfair headings for the BeenVoice identity.",
},
{
value: "platform",
label: "Platform",
description: "Native system headings for a neutral app feel.",
},
{
value: "inter",
label: "Inter",
description: "Inter headings for a plain shadcn-style baseline.",
},
{
value: "serif",
label: "Editorial",
description: "Playfair headings with a stronger editorial tone.",
},
];
export const radiusPreferences: {
value: RadiusPreference;
label: string;
description: string;
}[] = [
{ value: "none", label: "Square", description: "No rounded corners." },
{ value: "sm", label: "Small", description: "Subtle 4px rounding." },
{ value: "md", label: "Medium", description: "Standard 8px rounding." },
{ value: "lg", label: "Large", description: "Soft 12px rounding." },
{
value: "xl",
label: "Extra Large",
description: "Expressive 16px rounding.",
},
];
export const sidebarStyles: {
value: SidebarStyle;
label: string;
description: string;
}[] = [
{
value: "floating",
label: "Floating",
description: "Inset navigation with rounded edges and elevation.",
},
{
value: "docked",
label: "Flush",
description: "Full-height navigation aligned to the viewport edge.",
},
];
export const colorThemes: {
value: ColorTheme;
label: string;
swatch: string;
}[] = [
{ value: "slate", label: "Slate", swatch: "hsl(240 5.9% 10%)" },
{ value: "blue", label: "Blue", swatch: "hsl(221.2 83.2% 53.3%)" },
{ value: "green", label: "Green", swatch: "hsl(142.1 76.2% 36.3%)" },
{ value: "rose", label: "Rose", swatch: "hsl(346.8 77.2% 49.8%)" },
{ value: "orange", label: "Orange", swatch: "hsl(24.6 95% 53.1%)" },
];
export const colorModes: {
value: ColorMode;
label: string;
description: string;
}[] = [
{ value: "system", label: "System", description: "Follow device setting." },
{ value: "light", label: "Light", description: "Always use light mode." },
{ value: "dark", label: "Dark", description: "Always use dark mode." },
];
export const defaultInterfaceTheme: InterfaceTheme =
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? "beenvoice";
export const defaultFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_FONT ?? "brand";
export const defaultBodyFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
export const defaultHeadingFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
export const defaultRadiusPreference: RadiusPreference =
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? "xl";
export const defaultSidebarStyle: SidebarStyle =
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? "floating";
export const brand = {
name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice",
tagline:
env.NEXT_PUBLIC_BRAND_TAGLINE ??
"Simple and efficient invoicing for freelancers and small businesses",
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
};
+148 -42
View File
@@ -118,6 +118,26 @@ interface InvoiceData {
} | null> | null; } | null> | null;
} }
export interface PDFGenerationSettings {
pdfTemplate?: "classic" | "minimal";
pdfAccentColor?: string;
pdfFooterText?: string;
pdfShowLogo?: boolean;
pdfShowPageNumbers?: boolean;
}
const defaultPDFSettings: Required<PDFGenerationSettings> = {
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
};
function resolvePDFSettings(settings?: PDFGenerationSettings) {
return { ...defaultPDFSettings, ...settings };
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { page: {
flexDirection: "column", flexDirection: "column",
@@ -668,11 +688,14 @@ function getColumnWidths(showRate: boolean) {
} }
// Dense header component (first page) // Dense header component (first page)
const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => ( const DenseHeader: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => (
<View style={styles.denseHeader}> <View style={styles.denseHeader}>
<View style={styles.headerTop}> <View style={styles.headerTop}>
<View style={styles.businessSection}> <View style={styles.businessSection}>
<Text style={styles.businessName}> <Text style={[styles.businessName, { color: settings.pdfAccentColor }]}>
{invoice.business?.name ?? "Your Business Name"} {invoice.business?.name ?? "Your Business Name"}
</Text> </Text>
{invoice.business?.email && ( {invoice.business?.email && (
@@ -708,7 +731,9 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
</View> </View>
<View style={styles.invoiceSection}> <View style={styles.invoiceSection}>
<Text style={styles.invoiceTitle}>INVOICE</Text> <Text style={[styles.invoiceTitle, { color: settings.pdfAccentColor }]}>
INVOICE
</Text>
<Text style={styles.invoiceNumber}> <Text style={styles.invoiceNumber}>
{invoice.invoicePrefix ?? "#"} {invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber} {invoice.invoiceNumber}
@@ -771,9 +796,14 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
); );
// Abridged header component (other pages) // Abridged header component (other pages)
const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => ( const AbridgedHeader: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => (
<View style={styles.abridgedHeader}> <View style={styles.abridgedHeader}>
<Text style={styles.abridgedBusinessName}> <Text
style={[styles.abridgedBusinessName, { color: settings.pdfAccentColor }]}
>
{invoice.business?.name ?? "Your Business Name"} {invoice.business?.name ?? "Your Business Name"}
</Text> </Text>
<View style={styles.abridgedInvoiceInfo}> <View style={styles.abridgedInvoiceInfo}>
@@ -787,19 +817,49 @@ const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
); );
// Table header component // Table header component
const TableHeader: React.FC<{ showRate: boolean }> = ({ showRate }) => { const TableHeader: React.FC<{
settings: Required<PDFGenerationSettings>;
showRate: boolean;
}> = ({ settings, showRate }) => {
const cols = getColumnWidths(showRate); const cols = getColumnWidths(showRate);
return ( return (
<View style={styles.tableHeader}> <View
style={[
styles.tableHeader,
settings.pdfTemplate === "minimal" ? { backgroundColor: "#ffffff" } : {},
]}
>
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text> <Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
<Text style={[styles.tableHeaderCell, { width: cols.description }]}> <Text style={[styles.tableHeaderCell, { width: cols.description }]}>
Description Description
</Text> </Text>
<Text style={[styles.tableHeaderCell, styles.tableHeaderHours, { width: cols.hours }]}>Hours</Text> <Text
style={[
styles.tableHeaderCell,
styles.tableHeaderHours,
{ width: cols.hours },
]}
>
Hours
</Text>
{showRate && ( {showRate && (
<Text style={[styles.tableHeaderCell, styles.tableHeaderRate]}>Rate</Text> <Text
style={[
styles.tableHeaderCell,
styles.tableHeaderRate,
{ width: cols.rate },
]}
>
Rate
</Text>
)} )}
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount, { width: cols.amount }]}> <Text
style={[
styles.tableHeaderCell,
styles.tableHeaderAmount,
{ width: cols.amount },
]}
>
Amount Amount
</Text> </Text>
</View> </View>
@@ -820,35 +880,40 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
); );
}; };
const Footer: React.FC = () => ( const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
settings,
}) => (
<View style={styles.footer} fixed> <View style={styles.footer} fixed>
<View style={styles.footerLogo}> <View style={styles.footerLogo}>
{/* eslint-disable-next-line jsx-a11y/alt-text */} {settings.pdfShowLogo && (
<Image <Image
src="/beenvoice-logo.png" src="/beenvoice-logo.png"
style={{ style={{
width: 120, width: 120,
height: 18, height: 18,
marginRight: 8, marginRight: 8,
}} }}
/> />
)}
<Text <Text
style={{ style={{
fontSize: 9, fontSize: 9,
fontFamily: "Frutiger", fontFamily: "Frutiger",
color: "#6b7280", color: "#6b7280",
marginLeft: 8, marginLeft: settings.pdfShowLogo ? 8 : 0,
}} }}
> >
Professional Invoicing {settings.pdfFooterText}
</Text> </Text>
</View> </View>
<Text {settings.pdfShowPageNumbers && (
style={styles.pageNumber} <Text
render={({ pageNumber, totalPages }) => style={styles.pageNumber}
`Page ${pageNumber} of ${totalPages}` render={({ pageNumber, totalPages }) =>
} `Page ${pageNumber} of ${totalPages}`
/> }
/>
)}
</View> </View>
); );
@@ -856,7 +921,8 @@ const Footer: React.FC = () => (
const TotalsSection: React.FC<{ const TotalsSection: React.FC<{
invoice: InvoiceData; invoice: InvoiceData;
items: Array<NonNullable<InvoiceData["items"]>[0]>; items: Array<NonNullable<InvoiceData["items"]>[0]>;
}> = ({ invoice, items }) => { settings: Required<PDFGenerationSettings>;
}> = ({ invoice, items, settings }) => {
const currency = invoice.currency ?? "USD"; const currency = invoice.currency ?? "USD";
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;
@@ -864,7 +930,18 @@ const TotalsSection: React.FC<{
return ( return (
<View style={styles.totalsContainer}> <View style={styles.totalsContainer}>
<View style={styles.totalsBox}> <View
style={[
styles.totalsBox,
settings.pdfTemplate === "minimal"
? {
backgroundColor: "#ffffff",
borderTop: "1px solid #e5e7eb",
paddingHorizontal: 0,
}
: {},
]}
>
<Text <Text
style={{ style={{
fontSize: 11, fontSize: 11,
@@ -896,7 +973,12 @@ const TotalsSection: React.FC<{
<View style={styles.finalTotalRow}> <View style={styles.finalTotalRow}>
<Text style={styles.finalTotalLabel}>TOTAL:</Text> <Text style={styles.finalTotalLabel}>TOTAL:</Text>
<Text style={styles.finalTotalAmount}> <Text
style={[
styles.finalTotalAmount,
{ color: settings.pdfAccentColor },
]}
>
{formatCurrency(total, currency)} {formatCurrency(total, currency)}
</Text> </Text>
</View> </View>
@@ -910,7 +992,11 @@ const TotalsSection: React.FC<{
}; };
// Main PDF component // Main PDF component
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => { const InvoicePDF: React.FC<{
invoice: InvoiceData;
settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => {
const settings = resolvePDFSettings(inputSettings);
const items = invoice.items?.filter(Boolean) ?? []; const items = invoice.items?.filter(Boolean) ?? [];
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;
@@ -928,15 +1014,15 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
<Page key={`page-${pageIndex}`} size="LETTER" style={styles.page}> <Page key={`page-${pageIndex}`} size="LETTER" style={styles.page}>
{/* Header */} {/* Header */}
{isFirstPage ? ( {isFirstPage ? (
<DenseHeader invoice={invoice} /> <DenseHeader invoice={invoice} settings={settings} />
) : ( ) : (
<AbridgedHeader invoice={invoice} /> <AbridgedHeader invoice={invoice} settings={settings} />
)} )}
{/* Table */} {/* Table */}
{hasItems && ( {hasItems && (
<View style={styles.tableContainer}> <View style={styles.tableContainer}>
<TableHeader showRate={showRate} /> <TableHeader settings={settings} showRate={showRate} />
{pageItems.map( {pageItems.map(
(item, index) => (item, index) =>
item && ( item && (
@@ -944,7 +1030,9 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
key={`${pageIndex}-${index}`} key={`${pageIndex}-${index}`}
style={[ style={[
styles.tableRow, styles.tableRow,
index % 2 === 0 ? styles.tableRowAlt : {}, settings.pdfTemplate === "classic" && index % 2 === 0
? styles.tableRowAlt
: {},
]} ]}
> >
<Text style={[styles.tableCell, styles.tableCellDate, { width: cols.date }]}> <Text style={[styles.tableCell, styles.tableCellDate, { width: cols.date }]}>
@@ -963,7 +1051,13 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
{item.hours} {item.hours}
</Text> </Text>
{showRate && ( {showRate && (
<Text style={[styles.tableCell, styles.tableCellRate]}> <Text
style={[
styles.tableCell,
styles.tableCellRate,
{ width: cols.rate },
]}
>
{formatCurrency(item.rate, currency)} {formatCurrency(item.rate, currency)}
</Text> </Text>
)} )}
@@ -982,12 +1076,16 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
{isLastPage && ( {isLastPage && (
<View style={styles.bottomSection}> <View style={styles.bottomSection}>
{invoice.notes && <NotesSection invoice={invoice} />} {invoice.notes && <NotesSection invoice={invoice} />}
<TotalsSection invoice={invoice} items={items} /> <TotalsSection
invoice={invoice}
items={items}
settings={settings}
/>
</View> </View>
)} )}
{/* Footer */} {/* Footer */}
<Footer /> <Footer settings={settings} />
</Page> </Page>
); );
})} })}
@@ -996,7 +1094,10 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
}; };
// Export functions // Export functions
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> { export async function generateInvoicePDF(
invoice: InvoiceData,
settings?: PDFGenerationSettings,
): Promise<void> {
try { try {
// Validate invoice data // Validate invoice data
if (!invoice) { if (!invoice) {
@@ -1012,7 +1113,9 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
} }
// Generate PDF blob // Generate PDF blob
const originalBlob = await pdf(<InvoicePDF invoice={invoice} />).toBlob(); const originalBlob = await pdf(
<InvoicePDF invoice={invoice} settings={settings} />,
).toBlob();
// Validate blob // Validate blob
if (!originalBlob || originalBlob.size === 0) { if (!originalBlob || originalBlob.size === 0) {
@@ -1038,6 +1141,7 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
// Additional utility function for generating PDF without downloading // Additional utility function for generating PDF without downloading
export async function generateInvoicePDFBlob( export async function generateInvoicePDFBlob(
invoice: InvoiceData, invoice: InvoiceData,
settings?: PDFGenerationSettings,
): Promise<Blob> { ): Promise<Blob> {
try { try {
// Validate invoice data // Validate invoice data
@@ -1054,7 +1158,9 @@ export async function generateInvoicePDFBlob(
} }
// Generate PDF blob // Generate PDF blob
const originalBlob = await pdf(<InvoicePDF invoice={invoice} />).toBlob(); const originalBlob = await pdf(
<InvoicePDF invoice={invoice} settings={settings} />,
).toBlob();
// Validate blob // Validate blob
if (!originalBlob || originalBlob.size === 0) { if (!originalBlob || originalBlob.size === 0) {
+22 -10
View File
@@ -1,15 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { Resend } from "resend"; import { Resend } from "resend";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices } from "~/server/db/schema"; import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { env } from "~/env"; import { env } from "~/env";
import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
// Default Resend instance - will be overridden if business has custom API key
const defaultResend = new Resend(env.RESEND_API_KEY);
export const emailRouter = createTRPCRouter({ export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure sendInvoice: protectedProcedure
.input( .input(
@@ -56,7 +53,19 @@ export const emailRouter = createTRPCRouter({
// Generate PDF for attachment // Generate PDF for attachment
let pdfBuffer: Buffer; let pdfBuffer: Buffer;
try { try {
const pdfBlob = await generateInvoicePDFBlob(invoice); const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
const pdfBlob = await generateInvoicePDFBlob(invoice, {
pdfTemplate: settings?.pdfTemplate as
| "classic"
| "minimal"
| undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
});
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer()); pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
// Validate PDF was generated successfully // Validate PDF was generated successfully
@@ -126,14 +135,17 @@ export const emailRouter = createTRPCRouter({
: invoice.business.name) ?? : invoice.business.name) ??
userName; userName;
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`; fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
} else if (env.RESEND_DOMAIN) { } else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
// Use system Resend configuration // Use system Resend configuration
resendInstance = defaultResend; resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = `noreply@${env.RESEND_DOMAIN}`; fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? "noreply@example.com";
} else { } else {
// Fallback to business email if no configured domains throw new Error(
resendInstance = defaultResend; "Email delivery is not configured. Add a Resend API key globally or on this business.",
fromEmail = invoice.business?.email ?? "noreply@yourdomain.com"; );
} }
// Prepare CC and BCC lists // Prepare CC and BCC lists
+191 -19
View File
@@ -1,14 +1,48 @@
import { z } from "zod"; import { z } from "zod";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "~/server/api/trpc";
import { import {
users, users,
clients, clients,
businesses, businesses,
invoices, invoices,
invoiceItems, invoiceItems,
platformSettings,
} from "~/server/db/schema"; } from "~/server/db/schema";
import {
defaultBodyFontPreference,
defaultFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
async function requireAdmin(ctx: {
db: typeof import("~/server/db").db;
session: { user: { id: string } };
}) {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: { role: true },
});
if (user?.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
// Validation schemas for backup data // Validation schemas for backup data
const ClientBackupSchema = z.object({ const ClientBackupSchema = z.object({
@@ -76,6 +110,37 @@ const BackupDataSchema = z.object({
}); });
export const settingsRouter = createTRPCRouter({ export const settingsRouter = createTRPCRouter({
listAccounts: protectedProcedure.query(async ({ ctx }) => {
await requireAdmin(ctx);
return ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
email: true,
role: true,
emailVerified: true,
createdAt: true,
},
orderBy: (users, { asc }) => [asc(users.createdAt)],
});
}),
updateAccountRole: protectedProcedure
.input(
z.object({
userId: z.string().min(1),
role: z.enum(["user", "admin"]),
}),
)
.mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
await ctx.db
.update(users)
.set({ role: input.role })
.where(eq(users.id, input.userId));
return { success: true };
}),
// Get user profile information // Get user profile information
getProfile: protectedProcedure.query(async ({ ctx }) => { getProfile: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({ const user = await ctx.db.query.users.findFirst({
@@ -85,6 +150,7 @@ export const settingsRouter = createTRPCRouter({
name: true, name: true,
email: true, email: true,
image: true, image: true,
role: true,
}, },
}); });
@@ -144,20 +210,41 @@ export const settingsRouter = createTRPCRouter({
}), }),
// Get theme preferences // Get theme preferences
getTheme: protectedProcedure.query(async ({ ctx }) => { getTheme: publicProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({ const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(users.id, ctx.session.user.id), where: eq(platformSettings.id, "global"),
columns: {
colorTheme: true,
customColor: true,
theme: true,
},
}); });
return { return {
colorTheme: (user?.colorTheme as "slate" | "blue" | "green" | "rose" | "orange" | "custom") ?? "slate", colorTheme: (settings?.colorTheme as ColorTheme) ?? "slate",
customColor: user?.customColor ?? undefined, customColor: settings?.customColor ?? undefined,
theme: (user?.theme as "light" | "dark" | "system") ?? "system", theme: (settings?.theme as ColorMode) ?? "system",
interfaceTheme:
(settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme,
fontPreference: defaultFontPreference,
bodyFontPreference:
(settings?.bodyFontPreference as FontPreference) ??
defaultBodyFontPreference,
headingFontPreference:
(settings?.headingFontPreference as FontPreference) ??
defaultHeadingFontPreference,
radiusPreference:
(settings?.radiusPreference as RadiusPreference) ??
defaultRadiusPreference,
sidebarStyle:
(settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle,
brandName: settings?.brandName ?? "beenvoice",
brandTagline:
settings?.brandTagline ??
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: settings?.brandLogoText ?? "beenvoice",
brandIcon: settings?.brandIcon ?? "$",
pdfTemplate:
(settings?.pdfTemplate as "classic" | "minimal") ?? "classic",
pdfAccentColor: settings?.pdfAccentColor ?? "#111827",
pdfFooterText: settings?.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: settings?.pdfShowLogo ?? true,
pdfShowPageNumbers: settings?.pdfShowPageNumbers ?? true,
}; };
}), }),
@@ -165,20 +252,105 @@ export const settingsRouter = createTRPCRouter({
updateTheme: protectedProcedure updateTheme: protectedProcedure
.input( .input(
z.object({ z.object({
colorTheme: z.enum(["slate", "blue", "green", "rose", "orange", "custom"]).optional(), colorTheme: z
.enum(["slate", "blue", "green", "rose", "orange", "custom"])
.optional(),
customColor: z.string().optional(), customColor: z.string().optional(),
theme: z.enum(["light", "dark", "system"]).optional(), theme: z.enum(["light", "dark", "system"]).optional(),
interfaceTheme: z
.enum(["beenvoice", "shadcn", "minimal", "editorial"])
.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(),
brandTagline: z.string().min(1).max(255).optional(),
brandLogoText: z.string().min(1).max(100).optional(),
brandIcon: z.string().min(1).max(20).optional(),
pdfTemplate: z.enum(["classic", "minimal"]).optional(),
pdfAccentColor: z.string().min(4).max(50).optional(),
pdfFooterText: z.string().min(1).max(120).optional(),
pdfShowLogo: z.boolean().optional(),
pdfShowPageNumbers: z.boolean().optional(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
await ctx.db await ctx.db
.update(users) .insert(platformSettings)
.set({ .values({
...(input.colorTheme && { colorTheme: input.colorTheme }), id: "global",
...(input.customColor !== undefined && { customColor: input.customColor }), brandName: input.brandName ?? "beenvoice",
...(input.theme && { theme: input.theme }), brandTagline:
input.brandTagline ??
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: input.brandLogoText ?? "beenvoice",
brandIcon: input.brandIcon ?? "$",
colorTheme: input.colorTheme ?? "slate",
customColor: input.customColor,
theme: input.theme ?? "system",
interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme,
bodyFontPreference:
input.bodyFontPreference ?? defaultBodyFontPreference,
headingFontPreference:
input.headingFontPreference ?? defaultHeadingFontPreference,
radiusPreference: input.radiusPreference ?? defaultRadiusPreference,
sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle,
pdfTemplate: input.pdfTemplate ?? "classic",
pdfAccentColor: input.pdfAccentColor ?? "#111827",
pdfFooterText: input.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: input.pdfShowLogo ?? true,
pdfShowPageNumbers: input.pdfShowPageNumbers ?? true,
}) })
.where(eq(users.id, ctx.session.user.id)); .onConflictDoUpdate({
target: platformSettings.id,
set: {
...(input.brandName && { brandName: input.brandName }),
...(input.brandTagline && { brandTagline: input.brandTagline }),
...(input.brandLogoText && {
brandLogoText: input.brandLogoText,
}),
...(input.brandIcon && { brandIcon: input.brandIcon }),
...(input.colorTheme && { colorTheme: input.colorTheme }),
...(input.customColor !== undefined && {
customColor: input.customColor,
}),
...(input.theme && { theme: input.theme }),
...(input.interfaceTheme && {
interfaceTheme: input.interfaceTheme,
}),
...(input.bodyFontPreference && {
bodyFontPreference: input.bodyFontPreference,
}),
...(input.headingFontPreference && {
headingFontPreference: input.headingFontPreference,
}),
...(input.radiusPreference && {
radiusPreference: input.radiusPreference,
}),
...(input.sidebarStyle && { sidebarStyle: input.sidebarStyle }),
...(input.pdfTemplate && { pdfTemplate: input.pdfTemplate }),
...(input.pdfAccentColor && {
pdfAccentColor: input.pdfAccentColor,
}),
...(input.pdfFooterText && { pdfFooterText: input.pdfFooterText }),
...(input.pdfShowLogo !== undefined && {
pdfShowLogo: input.pdfShowLogo,
}),
...(input.pdfShowPageNumbers !== undefined && {
pdfShowPageNumbers: input.pdfShowPageNumbers,
}),
updatedAt: new Date(),
},
});
return { success: true }; return { success: true };
}), }),
+101 -16
View File
@@ -36,7 +36,10 @@ const migrationsFolder = path.resolve(__dirname, "../../../drizzle");
const pool = new Pool({ const pool = new Pool({
connectionString: databaseUrl, connectionString: databaseUrl,
ssl: process.env.DB_DISABLE_SSL === "true" ? false : { rejectUnauthorized: false }, ssl:
process.env.DB_DISABLE_SSL === "true"
? false
: { rejectUnauthorized: false },
max: 1, max: 1,
}); });
@@ -50,7 +53,11 @@ const db = drizzle(pool);
* so migrate() will re-run those migrations. * so migrate() will re-run those migrations.
*/ */
async function baselineIfNeeded(client: Pool) { async function baselineIfNeeded(client: Pool) {
const hasMigrationsTable = await tableExists(client, "drizzle", "__drizzle_migrations"); const hasMigrationsTable = await tableExists(
client,
"drizzle",
"__drizzle_migrations",
);
// Always ensure the drizzle schema + table exist // Always ensure the drizzle schema + table exist
await client.query(`CREATE SCHEMA IF NOT EXISTS drizzle`); await client.query(`CREATE SCHEMA IF NOT EXISTS drizzle`);
@@ -63,18 +70,24 @@ async function baselineIfNeeded(client: Pool) {
`); `);
const { rows: entryRows } = await client.query<{ count: string }>( const { rows: entryRows } = await client.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations` `SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations`,
); );
const hasEntries = parseInt(entryRows[0]?.count ?? "0") > 0; const hasEntries = parseInt(entryRows[0]?.count ?? "0") > 0;
if (!hasMigrationsTable || !hasEntries) { if (!hasMigrationsTable || !hasEntries) {
// No history at all — check if DB was previously set up via db:push // No history at all — check if DB was previously set up via db:push
const dbAlreadyExists = await tableExists(client, "public", "beenvoice_account"); const dbAlreadyExists = await tableExists(
client,
"public",
"beenvoice_account",
);
if (!dbAlreadyExists) { if (!dbAlreadyExists) {
return; // Fresh DB — let migrate() run everything normally return; // Fresh DB — let migrate() run everything normally
} }
console.log("[migrate] Existing database detected without migration history — baselining..."); console.log(
"[migrate] Existing database detected without migration history — baselining...",
);
await seedMigrationHistory(client); await seedMigrationHistory(client);
return; return;
} }
@@ -86,7 +99,7 @@ async function baselineIfNeeded(client: Pool) {
async function seedMigrationHistory(client: Pool) { async function seedMigrationHistory(client: Pool) {
const journal = JSON.parse( const journal = JSON.parse(
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8") fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8"),
) as { entries: { idx: number; tag: string; when: number }[] }; ) as { entries: { idx: number; tag: string; when: number }[] };
for (const entry of journal.entries) { for (const entry of journal.entries) {
@@ -96,12 +109,13 @@ async function seedMigrationHistory(client: Pool) {
continue; continue;
} }
const sql = fs.readFileSync( const sql = fs.readFileSync(
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8" path.join(migrationsFolder, `${entry.tag}.sql`),
"utf8",
); );
const hash = crypto.createHash("sha256").update(sql).digest("hex"); const hash = crypto.createHash("sha256").update(sql).digest("hex");
await client.query( await client.query(
`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`, `INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`,
[hash, entry.when] [hash, entry.when],
); );
console.log(`[migrate] Baselined: ${entry.tag}`); console.log(`[migrate] Baselined: ${entry.tag}`);
} }
@@ -111,16 +125,17 @@ async function seedMigrationHistory(client: Pool) {
async function removeBogusEntries(client: Pool) { async function removeBogusEntries(client: Pool) {
// Get all recorded hashes // Get all recorded hashes
const { rows } = await client.query<{ id: number; hash: string }>( const { rows } = await client.query<{ id: number; hash: string }>(
`SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id` `SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id`,
); );
const journal = JSON.parse( const journal = JSON.parse(
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8") fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8"),
) as { entries: { idx: number; tag: string; when: number }[] }; ) as { entries: { idx: number; tag: string; when: number }[] };
for (const entry of journal.entries) { for (const entry of journal.entries) {
const sql = fs.readFileSync( const sql = fs.readFileSync(
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8" path.join(migrationsFolder, `${entry.tag}.sql`),
"utf8",
); );
const expectedHash = crypto.createHash("sha256").update(sql).digest("hex"); const expectedHash = crypto.createHash("sha256").update(sql).digest("hex");
const recorded = rows.find((r) => r.hash === expectedHash); const recorded = rows.find((r) => r.hash === expectedHash);
@@ -129,17 +144,29 @@ async function removeBogusEntries(client: Pool) {
// It's recorded — verify it's actually applied in the schema // It's recorded — verify it's actually applied in the schema
const applied = await isMigrationApplied(client, entry.tag); const applied = await isMigrationApplied(client, entry.tag);
if (!applied) { if (!applied) {
console.log(`[migrate] Removing bogus migration record for: ${entry.tag}`); console.log(
await client.query(`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`, [recorded.id]); `[migrate] Removing bogus migration record for: ${entry.tag}`,
);
await client.query(
`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`,
[recorded.id],
);
} }
} }
} }
async function tableExists(client: Pool, schema: string, table: string): Promise<boolean> { async function tableExists(
const { rows } = await client.query<{ count: string }>(` client: Pool,
schema: string,
table: string,
): Promise<boolean> {
const { rows } = await client.query<{ count: string }>(
`
SELECT COUNT(*)::text AS count FROM information_schema.tables SELECT COUNT(*)::text AS count FROM information_schema.tables
WHERE table_schema = $1 AND table_name = $2 WHERE table_schema = $1 AND table_name = $2
`, [schema, table]); `,
[schema, table],
);
return parseInt(rows[0]?.count ?? "0") > 0; return parseInt(rows[0]?.count ?? "0") > 0;
} }
@@ -170,10 +197,68 @@ async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
`); `);
return parseInt(rows[0]?.count ?? "0") > 0; return parseInt(rows[0]?.count ?? "0") > 0;
} }
if (tag === "0003_appearance_preferences") {
// 0003 adds appearance preferences to beenvoice_user
const { rows } = await client.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'beenvoice_user'
AND column_name = 'interfaceTheme'
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0004_platform_appearance_controls") {
// 0004 adds platform-level appearance controls to beenvoice_user
const { rows } = await client.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'beenvoice_user'
AND column_name = 'sidebarStyle'
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0005_platform_settings_and_roles") {
const hasRole = await columnExists(
client,
"public",
"beenvoice_user",
"role",
);
const hasPlatformSettings = await tableExists(
client,
"public",
"beenvoice_platform_setting",
);
return hasRole && hasPlatformSettings;
}
if (tag === "0006_pdf_generation_settings") {
return columnExists(
client,
"public",
"beenvoice_platform_setting",
"pdfTemplate",
);
}
// Unknown migration — assume not applied so it runs // Unknown migration — assume not applied so it runs
return false; return false;
} }
async function columnExists(
client: Pool,
schema: string,
table: string,
column: string,
): Promise<boolean> {
const { rows } = await client.query<{ count: string }>(
`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2 AND column_name = $3
`,
[schema, table, column],
);
return parseInt(rows[0]?.count ?? "0") > 0;
}
console.log("[migrate] Running migrations from", migrationsFolder); console.log("[migrate] Running migrations from", migrationsFolder);
try { try {
+42
View File
@@ -35,6 +35,48 @@ export const users = createTable("user", (d) => ({
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(), colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }), customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(), theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
fontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
role: d.varchar({ length: 20 }).default("user").notNull(),
}));
export const platformSettings = createTable("platform_setting", (d) => ({
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
brandName: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandTagline: d
.varchar({ length: 255 })
.default(
"Simple and efficient invoicing for freelancers and small businesses",
)
.notNull(),
brandLogoText: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandIcon: d.varchar({ length: 20 }).default("$").notNull(),
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
pdfFooterText: d
.varchar({ length: 120 })
.default("Professional Invoicing")
.notNull(),
pdfShowLogo: d.boolean().default(true).notNull(),
pdfShowPageNumbers: d.boolean().default(true).notNull(),
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
})); }));
export const usersRelations = relations(users, ({ many }) => ({ export const usersRelations = relations(users, ({ many }) => ({
+310 -4
View File
@@ -34,8 +34,155 @@
/* 16px Global Radius */ /* 16px Global Radius */
} }
:root[data-interface-theme="shadcn"] {
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--radius: 0.5rem;
}
:root[data-interface-theme="beenvoice"] {
--secondary: 240 4.8% 90%;
--secondary-foreground: 240 5.9% 10%;
--radius: 1rem;
}
:root[data-interface-theme="minimal"] {
--background: 0 0% 100%;
--card: 0 0% 100%;
--popover: 0 0% 100%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 96.5%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 97%;
--accent: 240 4.8% 96%;
--accent-foreground: 240 5.9% 10%;
}
:root[data-interface-theme="editorial"] {
--background: 36 33% 98%;
--card: 36 33% 99%;
--popover: 36 33% 99%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 30 18% 91%;
--secondary-foreground: 24 10% 10%;
--muted: 30 20% 94%;
--accent: 346.8 77.2% 49.8%;
--accent-foreground: 355.7 100% 97.3%;
--border: 30 15% 86%;
--input: 30 15% 86%;
}
:root[data-body-font="brand"],
:root[data-body-font="inter"] {
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
:root[data-body-font="platform"] {
--app-font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-body-font="serif"] {
--app-font-sans:
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}
:root[data-heading-font="brand"],
:root[data-heading-font="serif"] {
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-heading-font="platform"] {
--app-font-heading:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-heading-font="inter"] {
--app-font-heading: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="brand"]:not([data-body-font]) {
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-font="platform"]:not([data-body-font]) {
--app-font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--app-font-heading:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-font="inter"]:not([data-body-font]) {
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="serif"]:not([data-body-font]) {
--app-font-sans:
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-radius="none"] {
--radius: 0rem;
}
:root[data-radius="sm"] {
--radius: 0.25rem;
}
:root[data-radius="md"] {
--radius: 0.5rem;
}
:root[data-radius="lg"] {
--radius: 0.75rem;
}
:root[data-radius="xl"] {
--radius: 1rem;
}
:root[data-color-mode="dark"],
:root.dark {
--background: 240 10% 3.9%;
/* #09090B */
--foreground: 0 0% 98%;
/* #FAFAFA */
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 20%;
/* #27272A */
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
/* #27272A */
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:not([data-color-mode="light"]) {
--background: 240 10% 3.9%; --background: 240 10% 3.9%;
/* #09090B */ /* #09090B */
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
@@ -61,6 +208,65 @@
--ring: 240 4.9% 83.9%; --ring: 240 4.9% 83.9%;
} }
} }
:root[data-color-theme="slate"] {
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
}
:root[data-color-theme="blue"] {
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--accent: 217.2 91.2% 59.8%;
--accent-foreground: 210 40% 98%;
}
:root[data-color-theme="green"] {
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--accent: 142.1 70.6% 45.3%;
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-theme="rose"] {
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--accent: 346.8 77.2% 49.8%;
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-theme="orange"] {
--primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%;
--accent: 20.5 90.2% 48.2%;
--accent-foreground: 60 9.1% 97.8%;
}
:root[data-color-theme="custom"] {
--primary: var(--custom-primary, 142.1 76.2% 36.3%);
--primary-foreground: 355.7 100% 97.3%;
--accent: var(--custom-primary, 142.1 76.2% 36.3%);
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-mode="dark"][data-color-theme="slate"],
:root.dark[data-color-theme="slate"] {
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
@media (prefers-color-scheme: dark) {
:root:not([data-color-mode="light"])[data-color-theme="slate"] {
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
}
} }
@theme inline { @theme inline {
@@ -84,8 +290,8 @@
--color-input: hsl(var(--input)); --color-input: hsl(var(--input));
--color-ring: hsl(var(--ring)); --color-ring: hsl(var(--ring));
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif; --font-sans: var(--app-font-sans), ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--font-heading), ui-serif, Georgia, serif; --font-heading: var(--app-font-heading), ui-serif, Georgia, serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace; --font-mono: var(--font-geist-mono), ui-monospace, monospace;
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
@@ -114,6 +320,87 @@
} }
@layer utilities { @layer utilities {
:root[data-interface-theme="shadcn"] .brand-background,
:root[data-interface-theme="minimal"] .brand-background {
display: none;
}
:root[data-interface-theme="minimal"] [data-slot="card"] {
background-color: transparent;
border-color: transparent;
border-radius: 0;
border-top-color: hsl(var(--border));
box-shadow: none;
backdrop-filter: none;
overflow: visible;
}
:root[data-interface-theme="minimal"] [data-slot="card"] + [data-slot="card"],
:root[data-interface-theme="minimal"] .form-section + .form-section {
border-top: 1px solid hsl(var(--border));
padding-top: 1rem;
}
:root[data-interface-theme="minimal"] [data-slot="card-header"],
:root[data-interface-theme="minimal"] [data-slot="card-content"],
:root[data-interface-theme="minimal"] [data-slot="card-footer"] {
padding-inline: 0;
}
:root[data-interface-theme="minimal"] [data-slot="card-header"] {
padding-top: 0.75rem;
padding-bottom: 0.5rem;
}
:root[data-interface-theme="minimal"] [data-slot="card-content"] {
padding-bottom: 0.75rem;
}
:root[data-interface-theme="minimal"] .page-enter,
:root[data-interface-theme="minimal"] [class*="space-y-8"],
:root[data-interface-theme="minimal"] [class*="space-y-6"] {
row-gap: 1rem;
}
:root[data-interface-theme="minimal"]
[class*="space-y-8"]
> :not([hidden])
~ :not([hidden]),
:root[data-interface-theme="minimal"]
[class*="space-y-6"]
> :not([hidden])
~ :not([hidden]) {
margin-top: 1rem;
}
:root[data-interface-theme="minimal"] [class*="gap-6"] {
gap: 1rem;
}
:root[data-interface-theme="minimal"] .platform-header-surface {
background-color: transparent;
border-color: transparent;
box-shadow: none;
backdrop-filter: none;
overflow: visible;
}
:root[data-interface-theme="minimal"] .platform-header-content {
padding: 0;
}
:root[data-interface-theme="minimal"] .platform-header-gradient {
display: none;
}
:root[data-interface-theme="minimal"] .bg-dashboard {
background-color: hsl(var(--background));
}
:root[data-interface-theme="editorial"] .brand-background {
opacity: 0.55;
}
.animate-blob { .animate-blob {
animation: blob 7s infinite; animation: blob 7s infinite;
} }
@@ -135,6 +422,25 @@
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1); box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1);
} }
:root[data-radius] .rounded-sm {
border-radius: var(--radius-sm);
}
:root[data-radius] .rounded,
:root[data-radius] .rounded-md {
border-radius: var(--radius-md);
}
:root[data-radius] .rounded-lg {
border-radius: var(--radius-lg);
}
:root[data-radius] .rounded-xl,
:root[data-radius] .rounded-2xl,
:root[data-radius] .rounded-3xl {
border-radius: var(--radius-xl);
}
} }
@keyframes blob { @keyframes blob {
@@ -153,4 +459,4 @@
100% { 100% {
transform: translate(0px, 0px) scale(1); transform: translate(0px, 0px) scale(1);
} }
} }
-123
View File
@@ -1,123 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[start.sh] Starting beenvoice in production mode"
# Detect if running inside a Docker container
IS_DOCKER=false
if [ -f /\.dockerenv ]; then
IS_DOCKER=true
fi
if [ "$IS_DOCKER" = false ]; then
## Host mode: prepare env, then run containers
if [ ! -f ./.env ] && { [ -f ./.env.example ] || [ -f ./env.example ]; }; then
echo "[start.sh] No .env detected. Creating from env.example with generated secrets..."
GEN_AUTH_SECRET=$(openssl rand -hex 32 || cat /proc/sys/kernel/random/uuid)
GEN_DB_PASSWORD=$(openssl rand -hex 16 || cat /proc/sys/kernel/random/uuid)
tmp_env=$(mktemp)
ENV_TEMPLATE="./.env.example"
if [ -f ./env.example ]; then ENV_TEMPLATE="./env.example"; fi
sed \
-e "s/^AUTH_SECRET=__GENERATE__/AUTH_SECRET=${GEN_AUTH_SECRET}/" \
-e "s/^POSTGRES_PASSWORD=__GENERATE__/POSTGRES_PASSWORD=${GEN_DB_PASSWORD}/" \
"$ENV_TEMPLATE" > "$tmp_env"
mv "$tmp_env" ./.env
echo "[start.sh] Created .env. Please review and edit it as needed, then run this script again."
exit 1
fi
# Auto-generate missing placeholders in existing .env
if [ -f ./.env ]; then
set -a; . ./.env; set +a
updated_env=false
if [ -z "${AUTH_SECRET:-}" ] || grep -qE '^AUTH_SECRET=($|__GENERATE__)' ./.env; then
new_auth_secret=$(openssl rand -hex 32 || cat /proc/sys/kernel/random/uuid)
sed -i.bak -e "s/^AUTH_SECRET=.*/AUTH_SECRET=${new_auth_secret}/" ./.env || echo "AUTH_SECRET=${new_auth_secret}" >> ./.env
updated_env=true
fi
if [ -z "${POSTGRES_PASSWORD:-}" ] || grep -qE '^POSTGRES_PASSWORD=($|__GENERATE__)' ./.env; then
new_db_pw=$(openssl rand -hex 16 || cat /proc/sys/kernel/random/uuid)
sed -i.bak -e "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${new_db_pw}/" ./.env || echo "POSTGRES_PASSWORD=${new_db_pw}" >> ./.env
updated_env=true
fi
if [ "$updated_env" = true ]; then rm -f ./.env.bak || true; fi
fi
# Ensure docker is available
if ! command -v docker >/dev/null 2>&1; then
echo "[start.sh] ERROR: docker is not installed or not in PATH." >&2
exit 1
fi
echo "[start.sh] Bringing up containers with docker compose..."
docker compose up -d
echo "[start.sh] Containers started. View logs with: docker compose logs -f app"
exit 0
fi
# Container mode: continue to runtime checks and start app
# If .env exists but secrets are missing or placeholders, auto-generate and update file
updated_env=false
if [ -f ./.env ]; then
if [ -z "${AUTH_SECRET:-}" ] || grep -qE '^AUTH_SECRET=($|__GENERATE__)' ./.env; then
new_auth_secret=$(openssl rand -hex 32 || cat /proc/sys/kernel/random/uuid)
sed -i.bak -e "s/^AUTH_SECRET=.*/AUTH_SECRET=${new_auth_secret}/" ./.env || echo "AUTH_SECRET=${new_auth_secret}" >> ./.env
AUTH_SECRET=${new_auth_secret}
updated_env=true
fi
if [ -z "${POSTGRES_PASSWORD:-}" ] || grep -qE '^POSTGRES_PASSWORD=($|__GENERATE__)' ./.env; then
new_db_pw=$(openssl rand -hex 16 || cat /proc/sys/kernel/random/uuid)
sed -i.bak -e "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${new_db_pw}/" ./.env || echo "POSTGRES_PASSWORD=${new_db_pw}" >> ./.env
POSTGRES_PASSWORD=${new_db_pw}
updated_env=true
fi
# Compose DATABASE_URL if missing but POSTGRES_* present
if [ -z "${DATABASE_URL:-}" ] && [ -n "${POSTGRES_USER:-}" ] && [ -n "${POSTGRES_PASSWORD:-}" ] && [ -n "${POSTGRES_DB:-}" ]; then
DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}"
echo "DATABASE_URL=${DATABASE_URL}" >> ./.env
updated_env=true
fi
# Reload env if we updated it
if [ "$updated_env" = true ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
rm -f ./.env.bak || true
fi
fi
# Ensure required env vars are present (fail fast for critical ones)
if [ -z "${DATABASE_URL:-}" ]; then
echo "[start.sh] ERROR: DATABASE_URL must be set (in .env or environment)." >&2
exit 1
fi
if [ -z "${AUTH_SECRET:-}" ]; then
echo "[start.sh] ERROR: AUTH_SECRET must be set (in .env or environment)." >&2
exit 1
fi
if [ -z "${RESEND_API_KEY:-}" ]; then
echo "[start.sh] ERROR: RESEND_API_KEY must be set (in .env or environment)." >&2
exit 1
fi
# Optional: allow skipping migrations with SKIP_DB_MIGRATION=true
SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION:-false}
if [ "$SKIP_DB_MIGRATION" != "true" ]; then
echo "[start.sh] Applying database migrations"
SKIP_ENV_VALIDATION=1 bun src/server/db/migrate.ts
else
echo "[start.sh] Skipping DB migration due to SKIP_DB_MIGRATION=${SKIP_DB_MIGRATION}"
fi
PORT=${PORT:-3000}
HOSTNAME_ENV=${HOSTNAME:-0.0.0.0}
echo "[start.sh] Starting Next.js server on ${HOSTNAME_ENV}:${PORT}"
exec bun run start -p "${PORT}" -H "${HOSTNAME_ENV}"