feat: Phase 4-5 — demo mode, polish, deploy, and bug fixes

Add demo mode with mock data provider, Docker deployment, Playwright
tests, PostHog analytics, error boundaries, and SEO metadata. Fix
residences API response unwrapping, kanban drag-and-drop with optimistic
updates, trailing slash proxy redirects, and column name mismatches with
Go API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-03 11:37:41 -06:00
parent 5a50d77515
commit 7884ebbfd4
133 changed files with 3904 additions and 300 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.git
.gitignore
*.md
.env*
.claude
tests
playwright-report
test-results
+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# claude
.claude/
+3
View File
@@ -0,0 +1,3 @@
WAIT=10
ATTEMPTS=6
/ Casera
+34
View File
@@ -0,0 +1,34 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -s /bin/sh -D nextjs
# Copy standalone build
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/ || exit 1
CMD ["node", "server.js"]
+15 -2
View File
@@ -1,7 +1,20 @@
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "casera.treytartt.com",
},
],
},
};
export default nextConfig;
export default withBundleAnalyzer(nextConfig);
+650 -8
View File
@@ -19,17 +19,21 @@
"date-fns": "^4.1.0",
"lucide-react": "^0.576.0",
"next": "16.1.6",
"posthog-js": "^1.357.2",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.2",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@next/bundle-analyzer": "^16.1.6",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
@@ -79,6 +83,19 @@
"nup": "bin/nup.mjs"
}
},
"node_modules/@axe-core/playwright": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
"integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.11.1"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -518,6 +535,16 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@discoveryjs/json-ext": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
"integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -2217,6 +2244,16 @@
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@next/bundle-analyzer": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.1.6.tgz",
"integrity": "sha512-ee2kagdTaeEWPlotgdTOqFHYcD3e2m2bbE3I9Rq2i6ABYi5OgopmtEUe8NM23viaYxLV2tDH/2nd5+qKoEr6cw==",
"dev": true,
"license": "MIT",
"dependencies": {
"webpack-bundle-analyzer": "4.10.1"
}
},
"node_modules/@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
@@ -2476,6 +2513,252 @@
"dev": true,
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
"integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/core": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
"integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/exporter-logs-otlp-http": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz",
"integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/otlp-exporter-base": "0.208.0",
"@opentelemetry/otlp-transformer": "0.208.0",
"@opentelemetry/sdk-logs": "0.208.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-exporter-base": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz",
"integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/otlp-transformer": "0.208.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz",
"integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
"@opentelemetry/sdk-logs": "0.208.0",
"@opentelemetry/sdk-metrics": "2.2.0",
"@opentelemetry/sdk-trace-base": "2.2.0",
"protobufjs": "^7.3.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz",
"integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.5.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz",
"integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz",
"integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz",
"integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
"integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz",
"integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -2492,6 +2775,92 @@
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@posthog/core": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.2.tgz",
"integrity": "sha512-zTDdda9NuSHrnwSOfFMxX/pyXiycF4jtU1kTr8DL61dHhV+7LF6XF1ndRZZTuaGGbfbb/GJYkEsjEX9SXfNZeQ==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.6"
}
},
"node_modules/@posthog/types": {
"version": "1.357.2",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.357.2.tgz",
"integrity": "sha512-1I78qKgAl78DNesAAe5HjmOuUc9MJKhClbErKHhwAi9rKualtPaM1fHMBqbUsBaeQRxhve/UJASrz0B5ihlP4Q==",
"license": "MIT"
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4955,7 +5324,6 @@
"version": "20.19.35",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz",
"integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -4988,6 +5356,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -5713,6 +6088,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -6560,6 +6948,17 @@
"node": ">=6.6.0"
}
},
"node_modules/core-js": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -6609,7 +7008,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -6848,6 +7246,13 @@
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -7032,6 +7437,15 @@
"node": ">=0.10.0"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -7060,6 +7474,13 @@
"node": ">= 0.4"
}
},
"node_modules/duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
"dev": true,
"license": "MIT"
},
"node_modules/eciesjs": {
"version": "0.4.17",
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz",
@@ -8104,6 +8525,12 @@
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fflate": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
"license": "MIT"
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
@@ -8564,6 +8991,22 @@
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/gzip-size": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
"integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"duplexer": "^0.1.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -8692,6 +9135,13 @@
"node": ">=16.9.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -9233,6 +9683,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -9451,7 +9911,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/iterator.prototype": {
@@ -9990,6 +10449,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -10169,6 +10634,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10677,6 +11152,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
"dev": true,
"license": "(WTFPL OR MIT)",
"bin": {
"opener": "bin/opener-bin.js"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -10872,7 +11357,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -11013,6 +11497,27 @@
"node": ">=4"
}
},
"node_modules/posthog-js": {
"version": "1.357.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.357.2.tgz",
"integrity": "sha512-f/7z56Xd7BC1TtWCzVVjU7m6NaiXiIK0Gc9shlwhi7weWt+OxJe59gNB6/etDoJHI9/Il8cjeXFZjAl+CA6ybQ==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.208.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
"@opentelemetry/resources": "^2.2.0",
"@opentelemetry/sdk-logs": "^0.208.0",
"@posthog/core": "1.23.2",
"@posthog/types": "1.357.2",
"core-js": "^3.38.1",
"dompurify": "^3.3.1",
"fflate": "^0.4.8",
"preact": "^10.28.2",
"query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.1.0"
}
},
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
@@ -11026,6 +11531,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/preact": {
"version": "10.28.4",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz",
"integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -11088,6 +11603,30 @@
"react-is": "^16.13.1"
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -11128,6 +11667,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/query-selector-shadow-dom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
"integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -12072,7 +12617,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -12085,7 +12629,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -12187,6 +12730,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sirv": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -12194,6 +12752,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -12703,6 +13271,16 @@
"node": ">=0.6"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
@@ -12965,7 +13543,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
@@ -13410,11 +13987,54 @@
"node": ">= 8"
}
},
"node_modules/web-vitals": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
"license": "Apache-2.0"
},
"node_modules/webpack-bundle-analyzer": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz",
"integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@discoveryjs/json-ext": "0.5.7",
"acorn": "^8.0.4",
"acorn-walk": "^8.0.0",
"commander": "^7.2.0",
"debounce": "^1.2.1",
"escape-string-regexp": "^4.0.0",
"gzip-size": "^6.0.0",
"html-escaper": "^2.0.2",
"is-plain-object": "^5.0.0",
"opener": "^1.5.2",
"picocolors": "^1.0.0",
"sirv": "^2.0.3",
"ws": "^7.3.1"
},
"bin": {
"webpack-bundle-analyzer": "lib/bin/analyzer.js"
},
"engines": {
"node": ">= 10.13.0"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -13609,6 +14229,28 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/wsl-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
+8 -1
View File
@@ -6,7 +6,10 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"analyze": "ANALYZE=true next build",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -20,17 +23,21 @@
"date-fns": "^4.1.0",
"lucide-react": "^0.576.0",
"next": "16.1.6",
"posthog-js": "^1.357.2",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.2",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@next/bundle-analyzer": "^16.1.6",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
screenshot: "only-on-failure",
trace: "on-first-retry",
},
projects: [
{
name: "Desktop Chrome",
use: { ...devices["Desktop Chrome"] },
},
{
name: "Mobile Safari",
use: { ...devices["iPhone 14"] },
},
{
name: "Tablet",
use: { viewport: { width: 768, height: 1024 } },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
+3 -2
View File
@@ -64,7 +64,7 @@ export default function ForgotPasswordPage() {
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
@@ -77,10 +77,11 @@ export default function ForgotPasswordPage() {
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
<p id="email-error" role="alert" className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
+5 -3
View File
@@ -44,7 +44,7 @@ export default function LoginPage() {
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
@@ -56,10 +56,11 @@ export default function LoginPage() {
placeholder="you@example.com"
autoComplete="username"
aria-invalid={!!errors.username}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")}
/>
{errors.username && (
<p className="text-sm text-destructive">
<p id="username-error" role="alert" className="text-sm text-destructive">
{errors.username.message}
</p>
)}
@@ -79,10 +80,11 @@ export default function LoginPage() {
id="password"
autoComplete="current-password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")}
/>
{errors.password && (
<p className="text-sm text-destructive">
<p id="password-error" role="alert" className="text-sm text-destructive">
{errors.password.message}
</p>
)}
+13 -7
View File
@@ -59,7 +59,7 @@ export default function RegisterPage() {
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
@@ -71,10 +71,11 @@ export default function RegisterPage() {
id="first_name"
autoComplete="given-name"
aria-invalid={!!errors.first_name}
aria-describedby={errors.first_name ? "first-name-error" : undefined}
{...register("first_name")}
/>
{errors.first_name && (
<p className="text-sm text-destructive">
<p id="first-name-error" role="alert" className="text-sm text-destructive">
{errors.first_name.message}
</p>
)}
@@ -86,10 +87,11 @@ export default function RegisterPage() {
id="last_name"
autoComplete="family-name"
aria-invalid={!!errors.last_name}
aria-describedby={errors.last_name ? "last-name-error" : undefined}
{...register("last_name")}
/>
{errors.last_name && (
<p className="text-sm text-destructive">
<p id="last-name-error" role="alert" className="text-sm text-destructive">
{errors.last_name.message}
</p>
)}
@@ -102,10 +104,11 @@ export default function RegisterPage() {
id="username"
autoComplete="username"
aria-invalid={!!errors.username}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")}
/>
{errors.username && (
<p className="text-sm text-destructive">
<p id="username-error" role="alert" className="text-sm text-destructive">
{errors.username.message}
</p>
)}
@@ -119,10 +122,11 @@ export default function RegisterPage() {
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
<p id="email-error" role="alert" className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
@@ -132,10 +136,11 @@ export default function RegisterPage() {
id="password"
autoComplete="new-password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")}
/>
{errors.password && (
<p className="text-sm text-destructive">
<p id="password-error" role="alert" className="text-sm text-destructive">
{errors.password.message}
</p>
)}
@@ -147,10 +152,11 @@ export default function RegisterPage() {
id="confirm_password"
autoComplete="new-password"
aria-invalid={!!errors.confirm_password}
aria-describedby={errors.confirm_password ? "confirm-password-error" : undefined}
{...register("confirm_password")}
/>
{errors.confirm_password && (
<p className="text-sm text-destructive">
<p id="confirm-password-error" role="alert" className="text-sm text-destructive">
{errors.confirm_password.message}
</p>
)}
+1 -1
View File
@@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
+1 -1
View File
@@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
+1 -1
View File
@@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
+2 -2
View File
@@ -12,11 +12,11 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
/**
* Build the target URL from the catch-all path segments.
* e.g. /api/proxy/tasks/123/ -> https://mycrib.treytartt.com/api/tasks/123/
* e.g. /api/proxy/tasks/123/ -> https://casera.treytartt.com/api/tasks/123/
*/
function buildTargetUrl(request: NextRequest, pathSegments: string[]): string {
const path = `/${pathSegments.join('/')}`;
+9 -2
View File
@@ -2,11 +2,13 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ContractorForm } from "@/components/contractors/contractor-form";
import { useContractor, useUpdateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function EditContractorPage({
@@ -17,6 +19,7 @@ export default function EditContractorPage({
const { id: idParam } = use(params);
const id = Number(idParam);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const updateContractor = useUpdateContractor(id);
@@ -24,7 +27,11 @@ export default function EditContractorPage({
function handleSubmit(data: ContractorFormValues) {
updateContractor.mutate(data, {
onSuccess: () => {
router.push(`/app/contractors/${id}`);
toast.success("Contractor updated");
router.push(`${basePath}/contractors/${id}`);
},
onError: () => {
toast.error("Failed to update contractor");
},
});
}
@@ -45,7 +52,7 @@ export default function EditContractorPage({
if (!contractor) return null;
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title={`Edit ${contractor.name}`} />
<ContractorForm
contractor={contractor}
+8 -1
View File
@@ -3,6 +3,7 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { Phone, Mail, Globe, Star, Pencil, Trash2, FileDown } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,7 @@ import {
useDeleteContractor,
useToggleFavorite,
} from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ContractorDetailPage({
params,
@@ -29,6 +31,7 @@ export default function ContractorDetailPage({
const { id: idParam } = use(params);
const id = Number(idParam);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const { data: tasks } = useContractorTasks(id);
@@ -40,7 +43,11 @@ export default function ContractorDetailPage({
function handleDelete() {
deleteContractor.mutate(id, {
onSuccess: () => {
router.push("/app/contractors");
toast.success("Contractor deleted");
router.push(`${basePath}/contractors`);
},
onError: () => {
toast.error("Failed to delete contractor");
},
});
}
+9 -2
View File
@@ -1,25 +1,32 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { ContractorForm } from "@/components/contractors/contractor-form";
import { useCreateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function NewContractorPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createContractor = useCreateContractor();
function handleSubmit(data: ContractorFormValues) {
createContractor.mutate(data, {
onSuccess: (res) => {
router.push(`/app/contractors/${res.id}`);
toast.success("Contractor created");
router.push(`${basePath}/contractors/${res.id}`);
},
onError: () => {
toast.error("Failed to create contractor");
},
});
}
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Contractor" />
<ContractorForm onSubmit={handleSubmit} loading={createContractor.isPending} />
</div>
+7 -5
View File
@@ -15,9 +15,11 @@ import { CaseraFileImport } from "@/components/sharing/casera-file-handler";
import { ContractorCard } from "@/components/contractors/contractor-card";
import { ContractorFilters } from "@/components/contractors/contractor-filters";
import { useContractors, useToggleFavorite, useCreateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ContractorsPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractors, isLoading, isError, error, refetch } = useContractors();
const toggleFavorite = useToggleFavorite();
const createContractor = useCreateContractor();
@@ -29,7 +31,7 @@ export default function ContractorsPage() {
const [importError, setImportError] = useState<string | null>(null);
const filtered = useMemo(() => {
if (!contractors) return [];
if (!Array.isArray(contractors)) return [];
let list = contractors;
// Search filter (name or company)
@@ -105,7 +107,7 @@ export default function ContractorsPage() {
title="Contractors"
description="Manage your trusted contractors and service providers"
actionLabel="Add Contractor"
onAction={() => router.push("/app/contractors/new")}
onAction={() => router.push(`${basePath}/contractors/new`)}
>
<Button
variant="outline"
@@ -129,7 +131,7 @@ export default function ContractorsPage() {
{isLoading && <LoadingSkeleton variant="list" count={5} />}
{!isLoading && !isError && contractors && (
{!isLoading && !isError && Array.isArray(contractors) && (
<>
<ContractorFilters
search={search}
@@ -145,12 +147,12 @@ export default function ContractorsPage() {
icon={Wrench}
title="No contractors found"
description={
contractors.length === 0
(contractors?.length ?? 0) === 0
? "Add your first contractor to keep track of service providers."
: "Try adjusting your search or filters."
}
actionLabel={contractors.length === 0 ? "Add Contractor" : undefined}
onAction={contractors.length === 0 ? () => router.push("/app/contractors/new") : undefined}
onAction={contractors.length === 0 ? () => router.push(`${basePath}/contractors/new`) : undefined}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
+9 -2
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { DocumentForm } from "@/components/documents/document-form";
import { useDocument, useUpdateDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface EditDocumentPageProps {
params: Promise<{ id: string }>;
@@ -17,6 +19,7 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: document, isLoading, error, refetch } = useDocument(id);
const updateDocument = useUpdateDocument(id);
@@ -41,7 +44,7 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
}
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader
title="Edit Document"
description={document.title}
@@ -53,7 +56,11 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
onSubmit={(data) => {
updateDocument.mutate(data, {
onSuccess: () => {
router.push(`/app/documents/${id}`);
toast.success("Document updated");
router.push(`${basePath}/documents/${id}`);
},
onError: () => {
toast.error("Failed to update document");
},
});
}}
+9 -2
View File
@@ -2,6 +2,7 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
Pencil,
Trash2,
@@ -20,6 +21,7 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { WarrantyStatus } from "@/components/documents/warranty-status";
import { ImageGallery } from "@/components/documents/image-gallery";
import { useDocument, useDeleteDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
const typeLabels: Record<string, string> = {
general: "General",
@@ -38,6 +40,7 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: document, isLoading, error, refetch } = useDocument(id);
const deleteDocument = useDeleteDocument();
@@ -96,7 +99,7 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/documents/${id}/edit`)}
onClick={() => router.push(`${basePath}/documents/${id}/edit`)}
>
<Pencil className="size-4 mr-2" />
Edit
@@ -229,7 +232,11 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
onConfirm={() => {
deleteDocument.mutate(id, {
onSuccess: () => {
router.push("/app/documents");
toast.success("Document deleted");
router.push(`${basePath}/documents`);
},
onError: () => {
toast.error("Failed to delete document");
},
});
}}
+9 -2
View File
@@ -1,17 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { DocumentForm } from "@/components/documents/document-form";
import { useCreateDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewDocumentPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createDocument = useCreateDocument();
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Document" description="Add a new document" />
<DocumentForm
@@ -21,7 +24,11 @@ export default function NewDocumentPage() {
{ data, file },
{
onSuccess: (res) => {
router.push(`/app/documents/${res.id}`);
toast.success("Document created");
router.push(`${basePath}/documents/${res.id}`);
},
onError: () => {
toast.error("Failed to create document");
},
},
);
+8 -6
View File
@@ -10,9 +10,11 @@ import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { DocumentCard } from "@/components/documents/document-card";
import { useDocuments, useWarranties } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function DocumentsPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const {
data: documents,
isLoading: documentsLoading,
@@ -32,7 +34,7 @@ export default function DocumentsPage() {
title="Documents"
description="Manage your property documents and warranties"
actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")}
onAction={() => router.push(`${basePath}/documents/new`)}
/>
<Tabs defaultValue="documents">
@@ -53,20 +55,20 @@ export default function DocumentsPage() {
{!documentsLoading &&
!documentsError &&
documents &&
Array.isArray(documents) &&
documents.length === 0 && (
<EmptyState
icon={FileText}
title="No documents yet"
description="Add your first document to start organizing your property records."
actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")}
onAction={() => router.push(`${basePath}/documents/new`)}
/>
)}
{!documentsLoading &&
!documentsError &&
documents &&
Array.isArray(documents) &&
documents.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{documents.map((doc) => (
@@ -88,7 +90,7 @@ export default function DocumentsPage() {
{!warrantiesLoading &&
!warrantiesError &&
warranties &&
Array.isArray(warranties) &&
warranties.length === 0 && (
<EmptyState
icon={FileText}
@@ -99,7 +101,7 @@ export default function DocumentsPage() {
{!warrantiesLoading &&
!warrantiesError &&
warranties &&
Array.isArray(warranties) &&
warranties.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{warranties.map((doc) => (
+17 -13
View File
@@ -3,23 +3,27 @@
import { Sidebar } from '@/components/layout/sidebar';
import { TopBar } from '@/components/layout/top-bar';
import { MobileNav } from '@/components/layout/mobile-nav';
import { DataProviderProvider } from '@/lib/demo/data-provider-context';
import { realProvider } from '@/lib/demo/real-provider';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background">
{/* Sidebar - hidden on mobile */}
<Sidebar />
<DataProviderProvider value={realProvider}>
<div className="min-h-screen bg-background">
{/* Sidebar - hidden on mobile */}
<Sidebar />
{/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children}
</main>
{/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children}
</main>
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div>
</DataProviderProvider>
);
}
+19 -5
View File
@@ -1,24 +1,38 @@
"use client";
import dynamic from "next/dynamic";
import { useResidences } from "@/lib/hooks/use-residences";
import { useAuthStore } from "@/stores/auth";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { StatsCards } from "@/components/dashboard/stats-cards";
import { TaskCompletionChart } from "@/components/dashboard/task-completion-chart";
import { RecentActivity } from "@/components/dashboard/recent-activity";
import { Skeleton } from "@/components/ui/skeleton";
const TaskCompletionChart = dynamic(
() => import("@/components/dashboard/task-completion-chart").then((mod) => ({ default: mod.TaskCompletionChart })),
{
loading: () => (
<div className="rounded-lg border p-6 space-y-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-[300px] w-full" />
</div>
),
}
);
export default function DashboardPage() {
const { data: residences, isLoading } = useResidences();
const user = useAuthStore((s) => s.user);
const list = Array.isArray(residences) ? residences : [];
const totalOverdue =
residences?.reduce((sum, r) => sum + r.task_summary.overdue, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.overdue ?? 0), 0);
const totalDueSoon =
residences?.reduce((sum, r) => sum + r.task_summary.due_soon, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.due_soon ?? 0), 0);
const totalActive =
residences?.reduce((sum, r) => sum + r.task_summary.in_progress, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.in_progress ?? 0), 0);
const totalCompleted =
residences?.reduce((sum, r) => sum + r.task_summary.completed, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.completed ?? 0), 0);
return (
<div className="space-y-8">
+9 -2
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ResidenceForm } from "@/components/residences/residence-form";
import { useResidence, useUpdateResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface EditResidencePageProps {
params: Promise<{ id: string }>;
@@ -17,6 +19,7 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id);
const updateResidence = useUpdateResidence(id);
@@ -41,7 +44,7 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
}
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader
title="Edit Residence"
description={residence.name}
@@ -53,7 +56,11 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
onSubmit={(data) => {
updateResidence.mutate(data, {
onSuccess: () => {
router.push(`/app/residences/${id}`);
toast.success("Residence updated");
router.push(`${basePath}/residences/${id}`);
},
onError: () => {
toast.error("Failed to update residence");
},
});
}}
+17 -7
View File
@@ -3,7 +3,8 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { MapPin, Pencil, Share2, Trash2, FileDown } from "lucide-react";
import * as residencesApi from "@/lib/api/residences";
import { toast } from "sonner";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -22,6 +23,7 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath, sharing } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id);
const { data: residences } = useResidences();
@@ -35,17 +37,21 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
setReportLoading(true);
setReportMessage(null);
try {
const result = await residencesApi.generateTasksReport(id);
setReportMessage(result.message || "Report sent to your email!");
const result = await sharing.generateTasksReport(id);
const msg = result.message || "Report sent to your email!";
setReportMessage(msg);
toast.success(msg);
} catch {
setReportMessage("Failed to generate report.");
toast.error("Failed to generate report");
} finally {
setReportLoading(false);
}
};
// Find the task summary from the residences list
const myResidence = residences?.find((r) => r.residence.id === id);
const resList = Array.isArray(residences) ? residences : [];
const myResidence = resList.find((r) => r.residence.id === id);
const taskSummary = myResidence?.task_summary;
if (isLoading) {
@@ -93,7 +99,7 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/residences/${id}/share`)}
onClick={() => router.push(`${basePath}/residences/${id}/share`)}
>
<Share2 className="size-4 mr-2" />
Share
@@ -111,7 +117,7 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/residences/${id}/edit`)}
onClick={() => router.push(`${basePath}/residences/${id}/edit`)}
>
<Pencil className="size-4 mr-2" />
Edit
@@ -193,7 +199,11 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
onConfirm={() => {
deleteResidence.mutate(id, {
onSuccess: () => {
router.push("/app/residences");
toast.success("Residence deleted");
router.push(`${basePath}/residences`);
},
onError: () => {
toast.error("Failed to delete residence");
},
});
}}
+3 -1
View File
@@ -11,6 +11,7 @@ import { ShareCodeDisplay } from "@/components/sharing/share-code-display";
import { UserManagement } from "@/components/sharing/user-management";
import { CaseraFileExport } from "@/components/sharing/casera-file-handler";
import { useResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface SharePageProps {
params: Promise<{ id: string }>;
@@ -20,6 +21,7 @@ export default function ResidenceSharePage({ params }: SharePageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id);
@@ -73,7 +75,7 @@ export default function ResidenceSharePage({ params }: SharePageProps) {
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/app/residences/${id}`)}
onClick={() => router.push(`${basePath}/residences/${id}`)}
>
<ArrowLeft className="size-4 mr-2" />
Back
+13 -2
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Home } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -12,9 +13,11 @@ import { PageHeader } from "@/components/shared/page-header";
import { ErrorBanner } from "@/components/shared/error-banner";
import { CaseraFileImport } from "@/components/sharing/casera-file-handler";
import { useJoinResidence } from "@/lib/hooks/use-sharing";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function JoinResidencePage() {
const router = useRouter();
const { basePath } = useDataProvider();
const joinResidence = useJoinResidence();
const [code, setCode] = useState("");
@@ -27,7 +30,11 @@ export default function JoinResidencePage() {
joinResidence.mutate(trimmed, {
onSuccess: () => {
router.push("/app/residences");
toast.success("Joined residence");
router.push(`${basePath}/residences`);
},
onError: () => {
toast.error("Failed to join residence");
},
});
}
@@ -45,7 +52,11 @@ export default function JoinResidencePage() {
const importedCode = (data as Record<string, unknown>).code as string;
joinResidence.mutate(importedCode, {
onSuccess: () => {
router.push("/app/residences");
toast.success("Joined residence");
router.push(`${basePath}/residences`);
},
onError: () => {
toast.error("Failed to join residence");
},
});
} else {
+9 -2
View File
@@ -1,17 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { ResidenceForm } from "@/components/residences/residence-form";
import { useCreateResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewResidencePage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createResidence = useCreateResidence();
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Residence" description="Add a new property" />
<ResidenceForm
@@ -19,7 +22,11 @@ export default function NewResidencePage() {
onSubmit={(data) => {
createResidence.mutate(data, {
onSuccess: (res) => {
router.push(`/app/residences/${res.id}`);
toast.success("Residence created");
router.push(`${basePath}/residences/${res.id}`);
},
onError: () => {
toast.error("Failed to create residence");
},
});
}}
+6 -4
View File
@@ -9,9 +9,11 @@ import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { ResidenceCard } from "@/components/residences/residence-card";
import { useResidences } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ResidencesPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const { data: residences, isLoading, error, refetch } = useResidences();
return (
@@ -20,7 +22,7 @@ export default function ResidencesPage() {
title="Residences"
description="Manage your properties"
actionLabel="Add Residence"
onAction={() => router.push("/app/residences/new")}
onAction={() => router.push(`${basePath}/residences/new`)}
/>
{isLoading && <LoadingSkeleton variant="card-grid" />}
@@ -32,17 +34,17 @@ export default function ResidencesPage() {
/>
)}
{!isLoading && !error && residences && residences.length === 0 && (
{!isLoading && !error && Array.isArray(residences) && residences.length === 0 && (
<EmptyState
icon={Home}
title="No residences yet"
description="Add your first property to start tracking tasks and maintenance."
actionLabel="Add Residence"
onAction={() => router.push("/app/residences/new")}
onAction={() => router.push(`${basePath}/residences/new`)}
/>
)}
{!isLoading && !error && residences && residences.length > 0 && (
{!isLoading && !error && Array.isArray(residences) && residences.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{residences.map((item) => (
<ResidenceCard key={item.residence.id} data={item} />
+10 -5
View File
@@ -4,15 +4,20 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { User, Bell, CreditCard } from "lucide-react";
import { useDataProvider } from "@/lib/demo/data-provider-context";
const settingsNav = [
{ label: "Profile", href: "/app/settings/profile", icon: User },
{ label: "Notifications", href: "/app/settings/notifications", icon: Bell },
{ label: "Subscription", href: "/app/settings/subscription", icon: CreditCard },
];
function getSettingsNav(basePath: string) {
return [
{ label: "Profile", href: `${basePath}/settings/profile`, icon: User },
{ label: "Notifications", href: `${basePath}/settings/notifications`, icon: Bell },
{ label: "Subscription", href: `${basePath}/settings/subscription`, icon: CreditCard },
];
}
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { basePath } = useDataProvider();
const settingsNav = getSettingsNav(basePath);
return (
<div className="space-y-6">
+13 -2
View File
@@ -1,5 +1,16 @@
import { redirect } from "next/navigation";
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function SettingsPage() {
redirect("/app/settings/profile");
const router = useRouter();
const { basePath } = useDataProvider();
useEffect(() => {
router.replace(`${basePath}/settings/profile`);
}, [router, basePath]);
return null;
}
+10 -1
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card";
import { TaskCompletionForm } from "@/components/tasks/task-completion-form";
import { useTask, useCreateCompletion } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function CompleteTaskPage({
params,
@@ -17,6 +19,7 @@ export default function CompleteTaskPage({
const { id } = use(params);
const taskId = Number(id);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const createCompletion = useCreateCompletion();
@@ -61,7 +64,13 @@ export default function CompleteTaskPage({
images,
},
{
onSuccess: () => router.push(`/app/tasks/${taskId}`),
onSuccess: () => {
toast.success("Task completed");
router.push(`${basePath}/tasks/${taskId}`);
},
onError: () => {
toast.error("Failed to complete task");
},
},
);
}}
+11 -2
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form";
import { useTask, useUpdateTask } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function EditTaskPage({
params,
@@ -17,6 +19,7 @@ export default function EditTaskPage({
const { id } = use(params);
const taskId = Number(id);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const updateTask = useUpdateTask(taskId);
@@ -39,7 +42,7 @@ export default function EditTaskPage({
if (!task) return null;
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="Edit Task" />
<Card>
@@ -48,7 +51,13 @@ export default function EditTaskPage({
task={task}
onSubmit={(data) => {
updateTask.mutate(data, {
onSuccess: () => router.push(`/app/tasks/${taskId}`),
onSuccess: () => {
toast.success("Task updated");
router.push(`${basePath}/tasks/${taskId}`);
},
onError: () => {
toast.error("Failed to update task");
},
});
}}
isSubmitting={updateTask.isPending}
+4 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { use } from "react";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/shared/page-header";
@@ -233,10 +234,12 @@ export default function TaskDetailPage({
{completion.images.length > 0 && (
<div className="flex gap-2 flex-wrap">
{completion.images.map((img) => (
<img
<Image
key={img.id}
src={img.image_url}
alt={img.caption || "Completion photo"}
width={80}
height={80}
className="size-20 rounded-md object-cover border"
/>
))}
+11 -2
View File
@@ -1,17 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form";
import { useCreateTask } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewTaskPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createTask = useCreateTask();
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Task" />
<Card>
@@ -19,7 +22,13 @@ export default function NewTaskPage() {
<TaskForm
onSubmit={(data) => {
createTask.mutate(data, {
onSuccess: () => router.push("/app/tasks"),
onSuccess: () => {
toast.success("Task created");
router.push(`${basePath}/tasks`);
},
onError: () => {
toast.error("Failed to create task");
},
});
}}
isSubmitting={createTask.isPending}
+11 -4
View File
@@ -2,18 +2,25 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import { ClipboardList } from "lucide-react";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { LookupSelect } from "@/components/shared/lookup-select";
import { KanbanBoard } from "@/components/tasks/kanban-board";
import { useTasks, useTasksByResidence } from "@/lib/hooks/use-tasks";
const KanbanBoard = dynamic(
() => import("@/components/tasks/kanban-board").then((mod) => ({ default: mod.KanbanBoard })),
{ loading: () => <LoadingSkeleton variant="kanban" /> }
);
import { useResidences } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function TasksPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const [selectedResidenceId, setSelectedResidenceId] = useState<
number | undefined
>();
@@ -25,7 +32,7 @@ export default function TasksPage() {
const activeQuery = selectedResidenceId ? filteredTasks : allTasks;
const { data, isLoading, isError, error, refetch } = activeQuery;
const residenceItems = (residences ?? []).map((r) => ({
const residenceItems = (Array.isArray(residences) ? residences : []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
@@ -39,7 +46,7 @@ export default function TasksPage() {
title="Tasks"
description="Manage your home maintenance tasks"
actionLabel="New Task"
onAction={() => router.push("/app/tasks/new")}
onAction={() => router.push(`${basePath}/tasks/new`)}
>
{residenceItems.length > 1 && (
<LookupSelect
@@ -68,7 +75,7 @@ export default function TasksPage() {
title="No tasks yet"
description="Create your first task to start tracking home maintenance."
actionLabel="New Task"
onAction={() => router.push("/app/tasks/new")}
onAction={() => router.push(`${basePath}/tasks/new`)}
/>
)}
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/[id]/edit/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/[id]/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/[id]/edit/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/[id]/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/page";
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { Sidebar } from '@/components/layout/sidebar';
import { TopBar } from '@/components/layout/top-bar';
import { MobileNav } from '@/components/layout/mobile-nav';
import { DemoBanner } from '@/components/demo/demo-banner';
import { DataProviderProvider } from '@/lib/demo/data-provider-context';
import { demoProvider } from '@/lib/demo/demo-provider';
export default function DemoAppLayout({ children }: { children: React.ReactNode }) {
return (
<DataProviderProvider value={demoProvider}>
<div className="min-h-screen bg-background">
<DemoBanner />
{/* Sidebar - hidden on mobile */}
<Sidebar />
{/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children}
</main>
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div>
</DataProviderProvider>
);
}
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/[id]/edit/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/[id]/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/[id]/share/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/join/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/layout";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/notifications/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/profile/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/subscription/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/[id]/complete/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/[id]/edit/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/[id]/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/page";
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Try Casera — Free Demo",
description:
"Try Casera without an account. Manage tasks, contractors, and documents in a live demo.",
openGraph: {
title: "Try Casera — Free Demo",
description:
"Try Casera without an account. Manage tasks, contractors, and documents in a live demo.",
type: "website",
},
};
export default function DemoLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function DemoLandingPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
<div className="mx-auto max-w-md text-center">
{/* Logo */}
<h1 className="mb-8 text-2xl font-bold tracking-tight text-primary">
Casera
</h1>
{/* Hero */}
<h2 className="text-3xl font-bold tracking-tight">
Try Casera &mdash; No Account Needed
</h2>
<p className="mt-3 text-muted-foreground">
Manage your home maintenance, track tasks, organize contractors, and
store documents.
</p>
{/* Actions */}
<div className="mt-8 flex flex-col gap-3">
<Button size="lg" asChild>
<Link href="/demo/app">Start Demo</Link>
</Button>
</div>
{/* Login link */}
<p className="mt-6 text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Log In
</Link>
</p>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] px-4">
<div className="rounded-full bg-destructive/10 p-4 mb-4">
<AlertTriangle className="size-10 text-destructive" />
</div>
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="mt-2 text-muted-foreground text-center max-w-md">
{error.message || "An unexpected error occurred. Please try again."}
</p>
<Button onClick={reset} className="mt-6">
Try Again
</Button>
</div>
);
}
+31 -5
View File
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/lib/themes/theme-provider";
import { QueryProvider } from "@/lib/query/query-provider";
import { PostHogProvider } from "@/lib/analytics/posthog-provider";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
@@ -15,8 +18,24 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Casera",
description: "Property management platform",
title: {
default: "Casera — Home Maintenance Made Simple",
template: "%s | Casera",
},
description:
"Track tasks, organize contractors, store documents. Manage your home maintenance in one place.",
openGraph: {
title: "Casera — Home Maintenance Made Simple",
description:
"Track tasks, organize contractors, store documents. Manage your home maintenance in one place.",
type: "website",
siteName: "Casera",
},
twitter: {
card: "summary_large_image",
title: "Casera — Home Maintenance Made Simple",
description: "Home Maintenance Made Simple",
},
};
export default function RootLayout({
@@ -29,9 +48,16 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<QueryProvider>
<ThemeProvider>{children}</ThemeProvider>
</QueryProvider>
<Suspense fallback={null}>
<PostHogProvider>
<QueryProvider>
<ThemeProvider>
{children}
<Toaster richColors closeButton />
</ThemeProvider>
</QueryProvider>
</PostHogProvider>
</Suspense>
</body>
</html>
);
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { FileQuestion } from "lucide-react";
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4">
<div className="rounded-full bg-muted p-4 mb-4">
<FileQuestion className="size-10 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold">Page not found</h2>
<p className="mt-2 text-muted-foreground text-center max-w-md">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Button asChild className="mt-6">
<Link href="/app/residences">Go Home</Link>
</Button>
</div>
);
}
@@ -5,6 +5,7 @@ import { Phone, Mail, Star } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardAction } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { ContractorResponse } from "@/lib/api/contractors";
interface ContractorCardProps {
@@ -13,10 +14,11 @@ interface ContractorCardProps {
}
export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) {
const { basePath } = useDataProvider();
return (
<Card className="transition-shadow hover:shadow-md">
<CardHeader>
<Link href={`/app/contractors/${contractor.id}`} className="hover:underline">
<Link href={`${basePath}/contractors/${contractor.id}`} className="hover:underline">
<CardTitle>{contractor.name}</CardTitle>
</Link>
{contractor.company && (
@@ -27,12 +29,14 @@ export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardP
variant="ghost"
size="icon"
className="size-8"
aria-label={contractor.is_favorite ? "Remove from favorites" : "Add to favorites"}
onClick={(e) => {
e.preventDefault();
onToggleFavorite(contractor.id);
}}
>
<Star
aria-hidden="true"
className={
contractor.is_favorite
? "size-4 fill-yellow-400 text-yellow-400"
@@ -56,15 +60,15 @@ export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardP
<div className="flex items-center gap-2">
{contractor.phone && (
<Button variant="outline" size="icon" className="size-8" asChild>
<a href={`tel:${contractor.phone}`} onClick={(e) => e.stopPropagation()}>
<Phone className="size-4" />
<a href={`tel:${contractor.phone}`} aria-label={`Call ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
<Phone className="size-4" aria-hidden="true" />
</a>
</Button>
)}
{contractor.email && (
<Button variant="outline" size="icon" className="size-8" asChild>
<a href={`mailto:${contractor.email}`} onClick={(e) => e.stopPropagation()}>
<Mail className="size-4" />
<a href={`mailto:${contractor.email}`} aria-label={`Email ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
<Mail className="size-4" aria-hidden="true" />
</a>
</Button>
)}
@@ -51,7 +51,7 @@ export function ContractorForm({ contractor, onSubmit, loading }: ContractorForm
const { data: specialties } = useContractorSpecialties();
const { data: residencesData } = useResidences();
const residenceItems = (residencesData ?? []).map((r) => ({
const residenceItems = (Array.isArray(residencesData) ? residencesData : []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
+3 -1
View File
@@ -5,9 +5,11 @@ import { Bell } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useNotifications } from "@/lib/hooks/use-notifications";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export function RecentActivity() {
const { data, isLoading } = useNotifications(5);
const { basePath } = useDataProvider();
const notifications = data?.results ?? [];
@@ -17,7 +19,7 @@ export function RecentActivity() {
<CardTitle className="flex items-center justify-between">
<span>Recent Activity</span>
<Link
href="/app/settings/notifications"
href={`${basePath}/settings/notifications`}
className="text-sm font-normal text-primary hover:underline"
>
View all
+3 -1
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { AlertTriangle, Clock, ClipboardList, CheckCircle2 } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface StatsCardsProps {
overdue: number;
@@ -44,11 +45,12 @@ const stats = [
export function StatsCards({ overdue, dueSoon, active, completed }: StatsCardsProps) {
const values: Record<string, number> = { overdue, dueSoon, active, completed };
const { basePath } = useDataProvider();
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat) => (
<Link key={stat.key} href="/app/tasks">
<Link key={stat.key} href={`${basePath}/tasks`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
export function DemoBanner() {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<div className="sticky top-0 z-50 flex items-center justify-center gap-3 border-b bg-muted/60 px-4 py-2 text-sm text-muted-foreground backdrop-blur-sm">
<p>
You&apos;re exploring Casera in demo mode. Changes aren&apos;t saved.
</p>
<Button size="xs" asChild>
<Link href="/register">Sign Up Free</Link>
</Button>
<Button
variant="ghost"
size="icon-xs"
className="absolute right-2"
onClick={() => setDismissed(true)}
aria-label="Dismiss banner"
>
<X />
</Button>
</div>
);
}
+6 -2
View File
@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import { FileText, FileImage, File, FileSpreadsheet } from "lucide-react";
import { format } from "date-fns";
@@ -5,6 +7,7 @@ import { format } from "date-fns";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { WarrantyStatus } from "@/components/documents/warranty-status";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { DocumentResponse } from "@/lib/api/documents";
interface DocumentCardProps {
@@ -29,13 +32,14 @@ const typeLabels: Record<string, string> = {
export function DocumentCard({ document: doc }: DocumentCardProps) {
const Icon = getFileIcon(doc.mime_type);
const { basePath } = useDataProvider();
return (
<Link href={`/app/documents/${doc.id}`} className="block">
<Link href={`${basePath}/documents/${doc.id}`} className="block">
<Card className="transition-colors hover:border-primary/40">
<CardHeader>
<div className="flex items-start gap-3">
<div className="rounded-md bg-muted p-2 shrink-0">
<div className="rounded-md bg-muted p-2 shrink-0" aria-hidden="true">
<Icon className="size-5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
+1 -1
View File
@@ -79,7 +79,7 @@ export function DocumentForm({
const { data: residences } = useResidences();
const [files, setFiles] = useState<File[]>([]);
const residenceItems = (residences ?? []).map((r) => ({
const residenceItems = (Array.isArray(residences) ? residences : []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
+8 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import {
Dialog,
DialogContent,
@@ -28,10 +29,12 @@ export function ImageGallery({ images }: ImageGalleryProps) {
className="group relative aspect-square overflow-hidden rounded-lg border bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none"
onClick={() => setSelectedImage(image)}
>
<img
<Image
src={image.image_url}
alt={image.caption || "Document image"}
className="size-full object-cover transition-transform group-hover:scale-105"
fill
sizes="(max-width: 640px) 50vw, 33vw"
className="object-cover transition-transform group-hover:scale-105"
/>
{image.caption && (
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1">
@@ -49,9 +52,11 @@ export function ImageGallery({ images }: ImageGalleryProps) {
</DialogHeader>
{selectedImage && (
<div className="flex justify-center">
<img
<Image
src={selectedImage.image_url}
alt={selectedImage.caption || "Document image"}
width={800}
height={600}
className="max-h-[70vh] w-auto rounded-md object-contain"
/>
</div>
+13 -8
View File
@@ -3,27 +3,32 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { navItems } from './nav-items';
// Show the first 5 nav items on mobile (exclude Settings)
const mobileNavItems = navItems.filter((item) => item.label !== 'Settings');
import { getNavItems } from './nav-items';
import { useDataProvider } from '@/lib/demo/data-provider-context';
export function MobileNav() {
const pathname = usePathname();
const { basePath } = useDataProvider();
const navItems = getNavItems(basePath);
// Show the first 5 nav items on mobile (exclude Settings)
const mobileNavItems = navItems.filter((item) => item.label !== 'Settings');
return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-card border-t border-border">
<nav role="navigation" aria-label="Main navigation" className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-card border-t border-border">
<div className="flex items-center justify-around px-2 py-2">
{mobileNavItems.map((item) => {
const isActive =
item.href === '/app'
? pathname === '/app'
item.href === basePath
? pathname === basePath
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors',
isActive
@@ -31,7 +36,7 @@ export function MobileNav() {
: 'text-muted-foreground hover:text-foreground'
)}
>
<item.icon className="size-5" />
<item.icon className="size-5" aria-hidden="true" />
<span>{item.label}</span>
</Link>
);
+13 -8
View File
@@ -6,11 +6,16 @@ export interface NavItem {
icon: React.ComponentType<{ className?: string }>;
}
export const navItems: NavItem[] = [
{ label: 'Home', href: '/app', icon: Home },
{ label: 'Residences', href: '/app/residences', icon: Building2 },
{ label: 'Tasks', href: '/app/tasks', icon: CheckSquare },
{ label: 'Contractors', href: '/app/contractors', icon: HardHat },
{ label: 'Documents', href: '/app/documents', icon: FileText },
{ label: 'Settings', href: '/app/settings', icon: Settings },
];
export function getNavItems(basePath: string): NavItem[] {
return [
{ label: 'Home', href: basePath, icon: Home },
{ label: 'Residences', href: `${basePath}/residences`, icon: Building2 },
{ label: 'Tasks', href: `${basePath}/tasks`, icon: CheckSquare },
{ label: 'Contractors', href: `${basePath}/contractors`, icon: HardHat },
{ label: 'Documents', href: `${basePath}/documents`, icon: FileText },
{ label: 'Settings', href: `${basePath}/settings`, icon: Settings },
];
}
// Default export for backward compatibility
export const navItems: NavItem[] = getNavItems('/app');
+11 -6
View File
@@ -4,16 +4,19 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
import { navItems } from './nav-items';
import { getNavItems } from './nav-items';
import { useDataProvider } from '@/lib/demo/data-provider-context';
export function Sidebar() {
const pathname = usePathname();
const { basePath } = useDataProvider();
const navItems = getNavItems(basePath);
return (
<aside className="hidden md:flex md:flex-col md:fixed md:inset-y-0 md:left-0 md:z-30 w-16 lg:w-64 bg-card border-r border-border">
{/* Logo */}
<div className="flex items-center h-16 px-4 lg:px-6">
<Link href="/app" className="flex items-center gap-2">
<Link href={basePath} className="flex items-center gap-2">
<span className="text-xl font-bold text-primary">C</span>
<span className="hidden lg:inline text-xl font-bold text-foreground">
Casera
@@ -24,17 +27,19 @@ export function Sidebar() {
<Separator />
{/* Navigation */}
<nav className="flex-1 flex flex-col gap-1 p-2 lg:p-3">
<nav role="navigation" aria-label="Main navigation" className="flex-1 flex flex-col gap-1 p-2 lg:p-3">
{navItems.map((item) => {
const isActive =
item.href === '/app'
? pathname === '/app'
item.href === basePath
? pathname === basePath
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
'hover:bg-accent hover:text-accent-foreground',
@@ -43,7 +48,7 @@ export function Sidebar() {
: 'text-muted-foreground'
)}
>
<item.icon className="size-5 shrink-0" />
<item.icon className="size-5 shrink-0" aria-hidden="true" />
<span className="hidden lg:inline">{item.label}</span>
</Link>
);
+10 -4
View File
@@ -11,9 +11,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useDataProvider } from '@/lib/demo/data-provider-context';
export function TopBar() {
const router = useRouter();
const { basePath } = useDataProvider();
const handleLogout = async () => {
try {
@@ -21,7 +23,11 @@ export function TopBar() {
} catch {
// Continue with redirect even if the API call fails
}
router.push('/login');
if (basePath.startsWith('/demo')) {
router.push('/demo');
} else {
router.push('/login');
}
};
return (
@@ -39,18 +45,18 @@ export function TopBar() {
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<button aria-label="User menu" className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<Avatar>
<AvatarFallback>U</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => router.push('/app/settings')}>
<DropdownMenuItem onClick={() => router.push(`${basePath}/settings`)}>
<User className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/app/settings')}>
<DropdownMenuItem onClick={() => router.push(`${basePath}/settings`)}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
@@ -21,10 +21,10 @@ export function NotificationBell() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="size-5" />
<Button variant="ghost" size="icon" className="relative" aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : "Notifications"}>
<Bell className="size-5" aria-hidden="true" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground">
<span aria-hidden="true" className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
+4 -2
View File
@@ -6,9 +6,11 @@ import { CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useOnboardingStore } from "@/stores/onboarding";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export function CompleteStep() {
const router = useRouter();
const { basePath } = useDataProvider();
const { path, residenceId, complete } = useOnboardingStore();
useEffect(() => {
@@ -20,9 +22,9 @@ export function CompleteStep() {
const handleNavigate = () => {
if (isCreatePath && residenceId) {
router.push(`/app/residences/${residenceId}`);
router.push(`${basePath}/residences/${residenceId}`);
} else {
router.push("/app/residences");
router.push(`${basePath}/residences`);
}
};
+7 -2
View File
@@ -1,8 +1,11 @@
"use client";
import Link from "next/link";
import { MapPin } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { MyResidenceResponse } from "@/lib/api/residences";
interface ResidenceCardProps {
@@ -11,19 +14,21 @@ interface ResidenceCardProps {
export function ResidenceCard({ data }: ResidenceCardProps) {
const { residence, task_summary } = data;
const { basePath } = useDataProvider();
const address = [residence.street_address, residence.city, residence.state_province]
.filter(Boolean)
.join(", ");
return (
<Link href={`/app/residences/${residence.id}`} className="block">
<Link href={`${basePath}/residences/${residence.id}`} className="block">
<Card className="transition-colors hover:border-primary/40">
<CardHeader>
<CardTitle className="text-base">{residence.name}</CardTitle>
{address && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="size-3.5 shrink-0" />
<MapPin className="size-3.5 shrink-0" aria-hidden="true" />
<span className="sr-only">Address:</span>
<span className="truncate">{address}</span>
</div>
)}
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { Loader2, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -48,10 +49,12 @@ export function ChangePasswordForm() {
});
reset();
setSuccess(true);
toast.success("Password changed");
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to change password.";
setApiError(message);
toast.error("Failed to change password");
}
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -37,6 +38,7 @@ export function DeleteAccountSection() {
const message =
err instanceof Error ? err.message : "Failed to delete account.";
setApiError(message);
toast.error("Failed to delete account");
setIsDeleting(false);
}
}
@@ -1,5 +1,6 @@
"use client";
import { toast } from "sonner";
import { useNotificationPreferences, useUpdatePreferences } from "@/lib/hooks/use-notifications";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -45,7 +46,14 @@ export function NotificationPreferences() {
function handleToggle(key: keyof NotificationPreferencesResponse, checked: boolean) {
const update: UpdatePreferencesRequest = { [key]: checked };
updatePreferences.mutate(update);
updatePreferences.mutate(update, {
onSuccess: () => {
toast.success("Preferences updated");
},
onError: () => {
toast.error("Failed to update preferences");
},
});
}
if (isLoading) {
+3
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { Loader2, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -46,10 +47,12 @@ export function ProfileForm() {
await authApi.updateProfile(data);
await fetchUser();
setSuccess(true);
toast.success("Profile updated");
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to update profile.";
setApiError(message);
toast.error("Failed to update profile");
}
}
+2 -2
View File
@@ -9,8 +9,8 @@ interface ErrorBannerProps {
export function ErrorBanner({ message = "Something went wrong. Please try again.", onRetry }: ErrorBannerProps) {
return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 flex items-center gap-3">
<AlertTriangle className="size-5 text-destructive shrink-0" />
<div role="alert" aria-live="assertive" className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 flex items-center gap-3">
<AlertTriangle className="size-5 text-destructive shrink-0" aria-hidden="true" />
<p className="text-sm text-destructive flex-1">{message}</p>
{onRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}
</div>
+3 -2
View File
@@ -11,11 +11,12 @@ interface FormFieldProps {
}
export function FormField({ label, htmlFor, error, required, className, children }: FormFieldProps) {
const errorId = error ? `${htmlFor}-error` : undefined;
return (
<div className={cn("space-y-2", className)}>
<Label htmlFor={htmlFor}>{label}{required && <span className="text-destructive ml-1">*</span>}</Label>
<Label htmlFor={htmlFor}>{label}{required && <span className="text-destructive ml-1" aria-hidden="true">*</span>}{required && <span className="sr-only">(required)</span>}</Label>
{children}
{error && <p className="text-sm text-destructive">{error}</p>}
{error && <p id={errorId} role="alert" className="text-sm text-destructive">{error}</p>}
</div>
);
}
+10 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Copy, Check, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -21,11 +22,19 @@ export function ShareCodeDisplay({ residenceId }: ShareCodeDisplayProps) {
if (!shareCode) return;
await navigator.clipboard.writeText(shareCode.code);
setCopied(true);
toast.success("Code copied to clipboard");
setTimeout(() => setCopied(false), 2000);
}
function handleGenerate() {
generateCode.mutate();
generateCode.mutate(undefined, {
onSuccess: () => {
toast.success("Share code generated");
},
onError: () => {
toast.error("Failed to generate share code");
},
});
}
if (isLoading) {
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { UserMinus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -24,8 +25,12 @@ export function UserManagement({ residenceId }: UserManagementProps) {
if (!removeTarget) return;
removeUser.mutate(removeTarget.id, {
onSuccess: () => {
toast.success("Member removed");
setRemoveTarget(null);
},
onError: () => {
toast.error("Failed to remove member");
},
});
}
+69 -10
View File
@@ -1,17 +1,20 @@
"use client";
import { useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
DndContext,
PointerSensor,
pointerWithin,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { useMarkInProgress } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import { KanbanColumn } from "./kanban-column";
import type { KanbanResponse } from "@/lib/api/tasks";
import type { KanbanResponse, KanbanColumn as KanbanColumnType } from "@/lib/api/tasks";
interface KanbanBoardProps {
data: KanbanResponse;
@@ -19,14 +22,47 @@ interface KanbanBoardProps {
export function KanbanBoard({ data }: KanbanBoardProps) {
const router = useRouter();
const queryClient = useQueryClient();
const { basePath } = useDataProvider();
const markInProgress = useMarkInProgress();
// Local columns state for instant optimistic updates
const [columns, setColumns] = useState<KanbanColumnType[]>(data.columns);
// Sync with prop changes (e.g. after server refetch)
useEffect(() => {
setColumns(data.columns);
}, [data.columns]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const moveTask = useCallback(
(taskId: number, sourceColName: string, targetColName: string) => {
const sourceCol = columns.find((c) => c.name === sourceColName);
const task = sourceCol?.tasks.find((t) => t.id === taskId);
if (!task) return;
setColumns((prev) =>
prev.map((col) => {
if (col.name === sourceColName) {
const tasks = col.tasks.filter((t) => t.id !== taskId);
return { ...col, tasks, count: tasks.length };
}
if (col.name === targetColName) {
const tasks = [...col.tasks, task];
return { ...col, tasks, count: tasks.length };
}
return col;
})
);
},
[columns]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
@@ -35,19 +71,42 @@ export function KanbanBoard({ data }: KanbanBoardProps) {
const taskId = active.id as number;
const targetColumn = over.id as string;
if (targetColumn === "in_progress") {
markInProgress.mutate(taskId);
} else if (targetColumn === "completed") {
router.push(`/app/tasks/${taskId}/complete`);
// Find source column
const sourceCol = columns.find((col) =>
col.tasks.some((t) => t.id === taskId)
);
if (!sourceCol || sourceCol.name === targetColumn) return;
if (targetColumn === "completed_tasks") {
router.push(`${basePath}/tasks/${taskId}/complete`);
return;
}
if (targetColumn === "in_progress_tasks") {
// Optimistic move + API call
moveTask(taskId, sourceCol.name, targetColumn);
markInProgress.mutate(taskId, {
onError: () => setColumns(data.columns),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
return;
}
// For any other column, just do an optimistic visual move
// (no API endpoint for moving to overdue/due_soon/upcoming — those are computed server-side)
moveTask(taskId, sourceCol.name, targetColumn);
// Refetch to get correct server state
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
[markInProgress, router]
[columns, data.columns, moveTask, markInProgress, queryClient, router, basePath]
);
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4">
{data.columns.map((column) => (
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory sm:snap-none -mx-4 px-4 sm:mx-0 sm:px-0">
{columns.map((column) => (
<KanbanColumn key={column.name} column={column} />
))}
</div>
+57 -37
View File
@@ -1,62 +1,86 @@
"use client";
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useRef, useEffect } from "react";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { TaskCard } from "./task-card";
import type { KanbanColumn as KanbanColumnType } from "@/lib/api/tasks";
const COLUMN_COLORS: Record<string, string> = {
overdue: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20",
due_today: "border-orange-500/50 bg-orange-50/50 dark:bg-orange-950/20",
due_soon: "border-yellow-500/50 bg-yellow-50/50 dark:bg-yellow-950/20",
upcoming: "border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/20",
in_progress: "border-green-500/50 bg-green-50/50 dark:bg-green-950/20",
completed: "border-gray-500/50 bg-gray-50/50 dark:bg-gray-950/20",
overdue_tasks: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20",
due_soon_tasks: "border-yellow-500/50 bg-yellow-50/50 dark:bg-yellow-950/20",
upcoming_tasks: "border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/20",
in_progress_tasks: "border-green-500/50 bg-green-50/50 dark:bg-green-950/20",
completed_tasks: "border-gray-500/50 bg-gray-50/50 dark:bg-gray-950/20",
cancelled_tasks: "border-slate-500/50 bg-slate-50/50 dark:bg-slate-950/20",
};
const COLUMN_HEADER_COLORS: Record<string, string> = {
overdue: "text-red-700 dark:text-red-400",
due_today: "text-orange-700 dark:text-orange-400",
due_soon: "text-yellow-700 dark:text-yellow-400",
upcoming: "text-blue-700 dark:text-blue-400",
in_progress: "text-green-700 dark:text-green-400",
completed: "text-gray-700 dark:text-gray-400",
overdue_tasks: "text-red-700 dark:text-red-400",
due_soon_tasks: "text-yellow-700 dark:text-yellow-400",
upcoming_tasks: "text-blue-700 dark:text-blue-400",
in_progress_tasks: "text-green-700 dark:text-green-400",
completed_tasks: "text-gray-700 dark:text-gray-400",
cancelled_tasks: "text-slate-700 dark:text-slate-400",
};
const COUNT_BADGE_COLORS: Record<string, string> = {
overdue: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
due_today: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
due_soon: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
upcoming: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
in_progress: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
completed: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
overdue_tasks: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
due_soon_tasks: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
upcoming_tasks: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
in_progress_tasks: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
completed_tasks: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
cancelled_tasks: "bg-slate-100 text-slate-800 dark:bg-slate-900 dark:text-slate-200",
};
interface KanbanColumnProps {
column: KanbanColumnType;
}
function SortableTask({ task }: { task: import("@/lib/api/tasks").TaskResponse }) {
function DraggableTask({ task }: { task: import("@/lib/api/tasks").TaskResponse }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
} = useDraggable({ id: task.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
const wasDragging = useRef(false);
useEffect(() => {
if (isDragging) {
wasDragging.current = true;
}
}, [isDragging]);
const style: React.CSSProperties = transform
? {
transform: `translate(${transform.x}px, ${transform.y}px)`,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? "relative" : undefined,
}
: undefined as unknown as React.CSSProperties;
// Block the click that fires after a drag ends so the Link doesn't navigate
const handleClick = (e: React.MouseEvent) => {
if (wasDragging.current) {
e.preventDefault();
e.stopPropagation();
wasDragging.current = false;
}
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClickCapture={handleClick}
>
<TaskCard task={task} isDragging={isDragging} />
</div>
);
@@ -67,12 +91,10 @@ export function KanbanColumn({ column }: KanbanColumnProps) {
id: column.name,
});
const taskIds = column.tasks.map((t) => t.id);
return (
<div
className={cn(
"flex flex-col min-w-[280px] max-w-[320px] rounded-lg border-2 p-3",
"flex flex-col min-w-[280px] sm:min-w-0 sm:flex-1 max-w-[320px] sm:max-w-none snap-center sm:snap-align-none rounded-lg border-2 p-3",
COLUMN_COLORS[column.name] ?? "border-border bg-muted/30",
isOver && "ring-2 ring-primary"
)}
@@ -95,11 +117,9 @@ export function KanbanColumn({ column }: KanbanColumnProps) {
</div>
<div ref={setNodeRef} className="flex-1 space-y-2 min-h-[60px]">
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
{column.tasks.map((task) => (
<SortableTask key={task.id} task={task} />
))}
</SortableContext>
{column.tasks.map((task) => (
<DraggableTask key={task.id} task={task} />
))}
{column.tasks.length === 0 && (
<div className="flex items-center justify-center h-[60px] text-xs text-muted-foreground rounded-md border border-dashed">
No tasks
+34 -8
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -26,6 +27,7 @@ import {
useArchiveTask,
useDeleteTask,
} from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface TaskActionsMenuProps {
taskId: number;
@@ -33,6 +35,7 @@ interface TaskActionsMenuProps {
export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) {
const router = useRouter();
const { basePath } = useDataProvider();
const [deleteOpen, setDeleteOpen] = useState(false);
const markInProgress = useMarkInProgress();
@@ -44,35 +47,54 @@ export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) {
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreVertical className="size-4" />
<Button variant="outline" size="icon" aria-label="Task actions">
<MoreVertical className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => router.push(`/app/tasks/${taskId}/complete`)}
onClick={() => router.push(`${basePath}/tasks/${taskId}/complete`)}
>
<CheckCircle className="size-4" />
Complete
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => markInProgress.mutate(taskId)}
onClick={() =>
markInProgress.mutate(taskId, {
onSuccess: () => toast.success("Task marked in progress"),
onError: () => toast.error("Failed to update task"),
})
}
>
<Play className="size-4" />
Mark In Progress
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push(`/app/tasks/${taskId}/edit`)}
onClick={() => router.push(`${basePath}/tasks/${taskId}/edit`)}
>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => cancelTask.mutate(taskId)}>
<DropdownMenuItem
onClick={() =>
cancelTask.mutate(taskId, {
onSuccess: () => toast.success("Task cancelled"),
onError: () => toast.error("Failed to cancel task"),
})
}
>
<XCircle className="size-4" />
Cancel
</DropdownMenuItem>
<DropdownMenuItem onClick={() => archiveTask.mutate(taskId)}>
<DropdownMenuItem
onClick={() =>
archiveTask.mutate(taskId, {
onSuccess: () => toast.success("Task archived"),
onError: () => toast.error("Failed to archive task"),
})
}
>
<Archive className="size-4" />
Archive
</DropdownMenuItem>
@@ -98,8 +120,12 @@ export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) {
onConfirm={() => {
deleteTask.mutate(taskId, {
onSuccess: () => {
toast.success("Task deleted");
setDeleteOpen(false);
router.push("/app/tasks");
router.push(`${basePath}/tasks`);
},
onError: () => {
toast.error("Failed to delete task");
},
});
}}
+7 -3
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Calendar, DollarSign } from "lucide-react";
import { cn } from "@/lib/utils";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { TaskResponse } from "@/lib/api/tasks";
interface TaskCardProps {
@@ -12,8 +13,9 @@ interface TaskCardProps {
}
export function TaskCard({ task, isDragging }: TaskCardProps) {
const { basePath } = useDataProvider();
return (
<Link href={`/app/tasks/${task.id}`}>
<Link href={`${basePath}/tasks/${task.id}`}>
<div
className={cn(
"rounded-lg border bg-card p-3 space-y-2 transition-shadow hover:shadow-md cursor-grab",
@@ -52,13 +54,15 @@ export function TaskCard({ task, isDragging }: TaskCardProps) {
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{task.due_date && (
<span className="flex items-center gap-1">
<Calendar className="size-3" />
<Calendar className="size-3" aria-hidden="true" />
<span className="sr-only">Due date:</span>
{new Date(task.due_date).toLocaleDateString()}
</span>
)}
{task.estimated_cost != null && task.estimated_cost > 0 && (
<span className="flex items-center gap-1">
<DollarSign className="size-3" />
<DollarSign className="size-3" aria-hidden="true" />
<span className="sr-only">Estimated cost:</span>
{task.estimated_cost.toFixed(2)}
</span>
)}
+2 -2
View File
@@ -49,12 +49,12 @@ export function TaskForm({ task, onSubmit, isSubmitting }: TaskFormProps) {
const { data: priorities } = useTaskPriorities();
const { data: frequencies } = useTaskFrequencies();
const residenceItems = (residences ?? []).map((r) => ({
const residenceItems = (Array.isArray(residences) ? residences : []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
const contractorItems = (contractors ?? []).map((c) => ({
const contractorItems = (Array.isArray(contractors) ? contractors : []).map((c) => ({
id: c.id,
name: c.company ? `${c.name} (${c.company})` : c.name,
}));
+1 -1
View File
@@ -61,7 +61,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
"fixed z-50 grid w-full gap-4 border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 inset-0 rounded-none max-h-screen overflow-y-auto sm:inset-auto sm:top-[50%] sm:left-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:max-w-lg sm:max-h-[85vh]",
className
)}
{...props}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { useThemeStore } from "@/stores/theme"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const mode = useThemeStore((s) => s.mode)
return (
<Sonner
theme={mode as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+47
View File
@@ -0,0 +1,47 @@
import posthog from "posthog-js";
let initialized = false;
export function initAnalytics() {
if (
typeof window !== "undefined" &&
process.env.NEXT_PUBLIC_POSTHOG_KEY &&
!initialized
) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host:
process.env.NEXT_PUBLIC_POSTHOG_HOST ||
"https://analytics.88oakapps.com",
capture_pageview: true,
capture_pageleave: true,
});
initialized = true;
}
}
export function trackEvent(
event: string,
properties?: Record<string, unknown>
) {
if (initialized) {
posthog.capture(event, properties);
}
}
export function trackScreen(screenName: string) {
if (initialized) {
posthog.capture("$pageview", { $current_url: screenName });
}
}
export function identifyUser(userId: string, traits?: Record<string, unknown>) {
if (initialized) {
posthog.identify(userId, traits);
}
}
export function resetAnalytics() {
if (initialized) {
posthog.reset();
}
}

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