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 # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts 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 type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
const nextConfig: NextConfig = { 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", "date-fns": "^4.1.0",
"lucide-react": "^0.576.0", "lucide-react": "^0.576.0",
"next": "16.1.6", "next": "16.1.6",
"posthog-js": "^1.357.2",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.3", "react": "19.2.3",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@next/bundle-analyzer": "^16.1.6",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
@@ -79,6 +83,19 @@
"nup": "bin/nup.mjs" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -518,6 +535,16 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT" "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": { "node_modules/@dnd-kit/accessibility": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -2217,6 +2244,16 @@
"@tybys/wasm-util": "^0.10.0" "@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": { "node_modules/@next/env": {
"version": "16.1.6", "version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
@@ -2476,6 +2513,252 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@playwright/test": {
"version": "1.58.2", "version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
@@ -2492,6 +2775,92 @@
"node": ">=18" "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": { "node_modules/@radix-ui/number": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4955,7 +5324,6 @@
"version": "20.19.35", "version": "20.19.35",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz",
"integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -4988,6 +5356,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "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" "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": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -6560,6 +6948,17 @@
"node": ">=6.6.0" "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": { "node_modules/cors": {
"version": "2.8.6", "version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -6609,7 +7008,6 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
@@ -6848,6 +7246,13 @@
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -7032,6 +7437,15 @@
"node": ">=0.10.0" "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": { "node_modules/dotenv": {
"version": "17.3.1", "version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -7060,6 +7474,13 @@
"node": ">= 0.4" "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": { "node_modules/eciesjs": {
"version": "0.4.17", "version": "0.4.17",
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz",
@@ -8104,6 +8525,12 @@
"node": "^12.20 || >= 14.13" "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": { "node_modules/figures": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", "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": "^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": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -8692,6 +9135,13 @@
"node": ">=16.9.0" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -9233,6 +9683,16 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/is-promise": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -9451,7 +9911,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/iterator.prototype": { "node_modules/iterator.prototype": {
@@ -9990,6 +10449,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -10169,6 +10634,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10677,6 +11152,16 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -10872,7 +11357,6 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -11013,6 +11497,27 @@
"node": ">=4" "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": { "node_modules/powershell-utils": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
@@ -11026,6 +11531,16 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -11088,6 +11603,30 @@
"react-is": "^16.13.1" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -11128,6 +11667,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -12072,7 +12617,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"shebang-regex": "^3.0.0" "shebang-regex": "^3.0.0"
@@ -12085,7 +12629,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -12187,6 +12730,21 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/sisteransi": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -12194,6 +12752,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -12703,6 +13271,16 @@
"node": ">=0.6" "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": { "node_modules/tough-cookie": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
@@ -12965,7 +13543,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicorn-magic": { "node_modules/unicorn-magic": {
@@ -13410,11 +13987,54 @@
"node": ">= 8" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
@@ -13609,6 +14229,28 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/wsl-utils": {
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
+8 -1
View File
@@ -6,7 +6,10 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"analyze": "ANALYZE=true next build",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -20,17 +23,21 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.576.0", "lucide-react": "^0.576.0",
"next": "16.1.6", "next": "16.1.6",
"posthog-js": "^1.357.2",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.3", "react": "19.2.3",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@next/bundle-analyzer": "^16.1.6",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@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"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && ( {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} {error}
</div> </div>
)} )}
@@ -77,10 +77,11 @@ export default function ForgotPasswordPage() {
placeholder="you@example.com" placeholder="you@example.com"
autoComplete="email" autoComplete="email"
aria-invalid={!!errors.email} aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")} {...register("email")}
/> />
{errors.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> </div>
+5 -3
View File
@@ -44,7 +44,7 @@ export default function LoginPage() {
> >
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && ( {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} {error}
</div> </div>
)} )}
@@ -56,10 +56,11 @@ export default function LoginPage() {
placeholder="you@example.com" placeholder="you@example.com"
autoComplete="username" autoComplete="username"
aria-invalid={!!errors.username} aria-invalid={!!errors.username}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")} {...register("username")}
/> />
{errors.username && ( {errors.username && (
<p className="text-sm text-destructive"> <p id="username-error" role="alert" className="text-sm text-destructive">
{errors.username.message} {errors.username.message}
</p> </p>
)} )}
@@ -79,10 +80,11 @@ export default function LoginPage() {
id="password" id="password"
autoComplete="current-password" autoComplete="current-password"
aria-invalid={!!errors.password} aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")} {...register("password")}
/> />
{errors.password && ( {errors.password && (
<p className="text-sm text-destructive"> <p id="password-error" role="alert" className="text-sm text-destructive">
{errors.password.message} {errors.password.message}
</p> </p>
)} )}
+13 -7
View File
@@ -59,7 +59,7 @@ export default function RegisterPage() {
> >
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && ( {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} {error}
</div> </div>
)} )}
@@ -71,10 +71,11 @@ export default function RegisterPage() {
id="first_name" id="first_name"
autoComplete="given-name" autoComplete="given-name"
aria-invalid={!!errors.first_name} aria-invalid={!!errors.first_name}
aria-describedby={errors.first_name ? "first-name-error" : undefined}
{...register("first_name")} {...register("first_name")}
/> />
{errors.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} {errors.first_name.message}
</p> </p>
)} )}
@@ -86,10 +87,11 @@ export default function RegisterPage() {
id="last_name" id="last_name"
autoComplete="family-name" autoComplete="family-name"
aria-invalid={!!errors.last_name} aria-invalid={!!errors.last_name}
aria-describedby={errors.last_name ? "last-name-error" : undefined}
{...register("last_name")} {...register("last_name")}
/> />
{errors.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} {errors.last_name.message}
</p> </p>
)} )}
@@ -102,10 +104,11 @@ export default function RegisterPage() {
id="username" id="username"
autoComplete="username" autoComplete="username"
aria-invalid={!!errors.username} aria-invalid={!!errors.username}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")} {...register("username")}
/> />
{errors.username && ( {errors.username && (
<p className="text-sm text-destructive"> <p id="username-error" role="alert" className="text-sm text-destructive">
{errors.username.message} {errors.username.message}
</p> </p>
)} )}
@@ -119,10 +122,11 @@ export default function RegisterPage() {
placeholder="you@example.com" placeholder="you@example.com"
autoComplete="email" autoComplete="email"
aria-invalid={!!errors.email} aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")} {...register("email")}
/> />
{errors.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> </div>
@@ -132,10 +136,11 @@ export default function RegisterPage() {
id="password" id="password"
autoComplete="new-password" autoComplete="new-password"
aria-invalid={!!errors.password} aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")} {...register("password")}
/> />
{errors.password && ( {errors.password && (
<p className="text-sm text-destructive"> <p id="password-error" role="alert" className="text-sm text-destructive">
{errors.password.message} {errors.password.message}
</p> </p>
)} )}
@@ -147,10 +152,11 @@ export default function RegisterPage() {
id="confirm_password" id="confirm_password"
autoComplete="new-password" autoComplete="new-password"
aria-invalid={!!errors.confirm_password} aria-invalid={!!errors.confirm_password}
aria-describedby={errors.confirm_password ? "confirm-password-error" : undefined}
{...register("confirm_password")} {...register("confirm_password")}
/> />
{errors.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} {errors.confirm_password.message}
</p> </p>
)} )}
+1 -1
View File
@@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = const API_BASE_URL =
process.env.API_URL || process.env.API_URL ||
process.env.NEXT_PUBLIC_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_NAME = 'casera-token';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days 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 = const API_BASE_URL =
process.env.API_URL || process.env.API_URL ||
process.env.NEXT_PUBLIC_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_NAME = 'casera-token';
+1 -1
View File
@@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = const API_BASE_URL =
process.env.API_URL || process.env.API_URL ||
process.env.NEXT_PUBLIC_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_NAME = 'casera-token';
+2 -2
View File
@@ -12,11 +12,11 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = const API_BASE_URL =
process.env.API_URL || process.env.API_URL ||
process.env.NEXT_PUBLIC_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. * 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 { function buildTargetUrl(request: NextRequest, pathSegments: string[]): string {
const path = `/${pathSegments.join('/')}`; const path = `/${pathSegments.join('/')}`;
+9 -2
View File
@@ -2,11 +2,13 @@
import { use } from "react"; import { use } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton"; import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner"; import { ErrorBanner } from "@/components/shared/error-banner";
import { ContractorForm } from "@/components/contractors/contractor-form"; import { ContractorForm } from "@/components/contractors/contractor-form";
import { useContractor, useUpdateContractor } from "@/lib/hooks/use-contractors"; import { useContractor, useUpdateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { ContractorFormValues } from "@/components/contractors/contractor-form"; import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function EditContractorPage({ export default function EditContractorPage({
@@ -17,6 +19,7 @@ export default function EditContractorPage({
const { id: idParam } = use(params); const { id: idParam } = use(params);
const id = Number(idParam); const id = Number(idParam);
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id); const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const updateContractor = useUpdateContractor(id); const updateContractor = useUpdateContractor(id);
@@ -24,7 +27,11 @@ export default function EditContractorPage({
function handleSubmit(data: ContractorFormValues) { function handleSubmit(data: ContractorFormValues) {
updateContractor.mutate(data, { updateContractor.mutate(data, {
onSuccess: () => { 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; if (!contractor) return null;
return ( 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}`} /> <PageHeader title={`Edit ${contractor.name}`} />
<ContractorForm <ContractorForm
contractor={contractor} contractor={contractor}
+8 -1
View File
@@ -3,6 +3,7 @@
import { use, useState } from "react"; import { use, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { toast } from "sonner";
import { Phone, Mail, Globe, Star, Pencil, Trash2, FileDown } from "lucide-react"; import { Phone, Mail, Globe, Star, Pencil, Trash2, FileDown } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -20,6 +21,7 @@ import {
useDeleteContractor, useDeleteContractor,
useToggleFavorite, useToggleFavorite,
} from "@/lib/hooks/use-contractors"; } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ContractorDetailPage({ export default function ContractorDetailPage({
params, params,
@@ -29,6 +31,7 @@ export default function ContractorDetailPage({
const { id: idParam } = use(params); const { id: idParam } = use(params);
const id = Number(idParam); const id = Number(idParam);
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id); const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const { data: tasks } = useContractorTasks(id); const { data: tasks } = useContractorTasks(id);
@@ -40,7 +43,11 @@ export default function ContractorDetailPage({
function handleDelete() { function handleDelete() {
deleteContractor.mutate(id, { deleteContractor.mutate(id, {
onSuccess: () => { 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"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
import { ContractorForm } from "@/components/contractors/contractor-form"; import { ContractorForm } from "@/components/contractors/contractor-form";
import { useCreateContractor } from "@/lib/hooks/use-contractors"; import { useCreateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { ContractorFormValues } from "@/components/contractors/contractor-form"; import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function NewContractorPage() { export default function NewContractorPage() {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const createContractor = useCreateContractor(); const createContractor = useCreateContractor();
function handleSubmit(data: ContractorFormValues) { function handleSubmit(data: ContractorFormValues) {
createContractor.mutate(data, { createContractor.mutate(data, {
onSuccess: (res) => { 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 ( 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" /> <PageHeader title="New Contractor" />
<ContractorForm onSubmit={handleSubmit} loading={createContractor.isPending} /> <ContractorForm onSubmit={handleSubmit} loading={createContractor.isPending} />
</div> </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 { ContractorCard } from "@/components/contractors/contractor-card";
import { ContractorFilters } from "@/components/contractors/contractor-filters"; import { ContractorFilters } from "@/components/contractors/contractor-filters";
import { useContractors, useToggleFavorite, useCreateContractor } from "@/lib/hooks/use-contractors"; import { useContractors, useToggleFavorite, useCreateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ContractorsPage() { export default function ContractorsPage() {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractors, isLoading, isError, error, refetch } = useContractors(); const { data: contractors, isLoading, isError, error, refetch } = useContractors();
const toggleFavorite = useToggleFavorite(); const toggleFavorite = useToggleFavorite();
const createContractor = useCreateContractor(); const createContractor = useCreateContractor();
@@ -29,7 +31,7 @@ export default function ContractorsPage() {
const [importError, setImportError] = useState<string | null>(null); const [importError, setImportError] = useState<string | null>(null);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!contractors) return []; if (!Array.isArray(contractors)) return [];
let list = contractors; let list = contractors;
// Search filter (name or company) // Search filter (name or company)
@@ -105,7 +107,7 @@ export default function ContractorsPage() {
title="Contractors" title="Contractors"
description="Manage your trusted contractors and service providers" description="Manage your trusted contractors and service providers"
actionLabel="Add Contractor" actionLabel="Add Contractor"
onAction={() => router.push("/app/contractors/new")} onAction={() => router.push(`${basePath}/contractors/new`)}
> >
<Button <Button
variant="outline" variant="outline"
@@ -129,7 +131,7 @@ export default function ContractorsPage() {
{isLoading && <LoadingSkeleton variant="list" count={5} />} {isLoading && <LoadingSkeleton variant="list" count={5} />}
{!isLoading && !isError && contractors && ( {!isLoading && !isError && Array.isArray(contractors) && (
<> <>
<ContractorFilters <ContractorFilters
search={search} search={search}
@@ -145,12 +147,12 @@ export default function ContractorsPage() {
icon={Wrench} icon={Wrench}
title="No contractors found" title="No contractors found"
description={ description={
contractors.length === 0 (contractors?.length ?? 0) === 0
? "Add your first contractor to keep track of service providers." ? "Add your first contractor to keep track of service providers."
: "Try adjusting your search or filters." : "Try adjusting your search or filters."
} }
actionLabel={contractors.length === 0 ? "Add Contractor" : undefined} 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"> <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 { use } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton"; import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner"; import { ErrorBanner } from "@/components/shared/error-banner";
import { DocumentForm } from "@/components/documents/document-form"; import { DocumentForm } from "@/components/documents/document-form";
import { useDocument, useUpdateDocument } from "@/lib/hooks/use-documents"; import { useDocument, useUpdateDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface EditDocumentPageProps { interface EditDocumentPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -17,6 +19,7 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
const { id: rawId } = use(params); const { id: rawId } = use(params);
const id = Number(rawId); const id = Number(rawId);
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { data: document, isLoading, error, refetch } = useDocument(id); const { data: document, isLoading, error, refetch } = useDocument(id);
const updateDocument = useUpdateDocument(id); const updateDocument = useUpdateDocument(id);
@@ -41,7 +44,7 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader <PageHeader
title="Edit Document" title="Edit Document"
description={document.title} description={document.title}
@@ -53,7 +56,11 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
onSubmit={(data) => { onSubmit={(data) => {
updateDocument.mutate(data, { updateDocument.mutate(data, {
onSuccess: () => { 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 { use, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { import {
Pencil, Pencil,
Trash2, Trash2,
@@ -20,6 +21,7 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { WarrantyStatus } from "@/components/documents/warranty-status"; import { WarrantyStatus } from "@/components/documents/warranty-status";
import { ImageGallery } from "@/components/documents/image-gallery"; import { ImageGallery } from "@/components/documents/image-gallery";
import { useDocument, useDeleteDocument } from "@/lib/hooks/use-documents"; import { useDocument, useDeleteDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
const typeLabels: Record<string, string> = { const typeLabels: Record<string, string> = {
general: "General", general: "General",
@@ -38,6 +40,7 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
const { id: rawId } = use(params); const { id: rawId } = use(params);
const id = Number(rawId); const id = Number(rawId);
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { data: document, isLoading, error, refetch } = useDocument(id); const { data: document, isLoading, error, refetch } = useDocument(id);
const deleteDocument = useDeleteDocument(); const deleteDocument = useDeleteDocument();
@@ -96,7 +99,7 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => router.push(`/app/documents/${id}/edit`)} onClick={() => router.push(`${basePath}/documents/${id}/edit`)}
> >
<Pencil className="size-4 mr-2" /> <Pencil className="size-4 mr-2" />
Edit Edit
@@ -229,7 +232,11 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
onConfirm={() => { onConfirm={() => {
deleteDocument.mutate(id, { deleteDocument.mutate(id, {
onSuccess: () => { 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"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
import { DocumentForm } from "@/components/documents/document-form"; import { DocumentForm } from "@/components/documents/document-form";
import { useCreateDocument } from "@/lib/hooks/use-documents"; import { useCreateDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewDocumentPage() { export default function NewDocumentPage() {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const createDocument = useCreateDocument(); const createDocument = useCreateDocument();
return ( 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" /> <PageHeader title="New Document" description="Add a new document" />
<DocumentForm <DocumentForm
@@ -21,7 +24,11 @@ export default function NewDocumentPage() {
{ data, file }, { data, file },
{ {
onSuccess: (res) => { 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 { EmptyState } from "@/components/shared/empty-state";
import { DocumentCard } from "@/components/documents/document-card"; import { DocumentCard } from "@/components/documents/document-card";
import { useDocuments, useWarranties } from "@/lib/hooks/use-documents"; import { useDocuments, useWarranties } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function DocumentsPage() { export default function DocumentsPage() {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { const {
data: documents, data: documents,
isLoading: documentsLoading, isLoading: documentsLoading,
@@ -32,7 +34,7 @@ export default function DocumentsPage() {
title="Documents" title="Documents"
description="Manage your property documents and warranties" description="Manage your property documents and warranties"
actionLabel="Add Document" actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")} onAction={() => router.push(`${basePath}/documents/new`)}
/> />
<Tabs defaultValue="documents"> <Tabs defaultValue="documents">
@@ -53,20 +55,20 @@ export default function DocumentsPage() {
{!documentsLoading && {!documentsLoading &&
!documentsError && !documentsError &&
documents && Array.isArray(documents) &&
documents.length === 0 && ( documents.length === 0 && (
<EmptyState <EmptyState
icon={FileText} icon={FileText}
title="No documents yet" title="No documents yet"
description="Add your first document to start organizing your property records." description="Add your first document to start organizing your property records."
actionLabel="Add Document" actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")} onAction={() => router.push(`${basePath}/documents/new`)}
/> />
)} )}
{!documentsLoading && {!documentsLoading &&
!documentsError && !documentsError &&
documents && Array.isArray(documents) &&
documents.length > 0 && ( documents.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{documents.map((doc) => ( {documents.map((doc) => (
@@ -88,7 +90,7 @@ export default function DocumentsPage() {
{!warrantiesLoading && {!warrantiesLoading &&
!warrantiesError && !warrantiesError &&
warranties && Array.isArray(warranties) &&
warranties.length === 0 && ( warranties.length === 0 && (
<EmptyState <EmptyState
icon={FileText} icon={FileText}
@@ -99,7 +101,7 @@ export default function DocumentsPage() {
{!warrantiesLoading && {!warrantiesLoading &&
!warrantiesError && !warrantiesError &&
warranties && Array.isArray(warranties) &&
warranties.length > 0 && ( warranties.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{warranties.map((doc) => ( {warranties.map((doc) => (
+17 -13
View File
@@ -3,23 +3,27 @@
import { Sidebar } from '@/components/layout/sidebar'; import { Sidebar } from '@/components/layout/sidebar';
import { TopBar } from '@/components/layout/top-bar'; import { TopBar } from '@/components/layout/top-bar';
import { MobileNav } from '@/components/layout/mobile-nav'; 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 }) { export default function AppLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="min-h-screen bg-background"> <DataProviderProvider value={realProvider}>
{/* Sidebar - hidden on mobile */} <div className="min-h-screen bg-background">
<Sidebar /> {/* Sidebar - hidden on mobile */}
<Sidebar />
{/* Main content area */} {/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen"> <div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar /> <TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6"> <main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children} {children}
</main> </main>
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div> </div>
</DataProviderProvider>
{/* Mobile bottom nav */}
<MobileNav />
</div>
); );
} }
+19 -5
View File
@@ -1,24 +1,38 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import { useResidences } from "@/lib/hooks/use-residences"; import { useResidences } from "@/lib/hooks/use-residences";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton"; import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { StatsCards } from "@/components/dashboard/stats-cards"; import { StatsCards } from "@/components/dashboard/stats-cards";
import { TaskCompletionChart } from "@/components/dashboard/task-completion-chart";
import { RecentActivity } from "@/components/dashboard/recent-activity"; 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() { export default function DashboardPage() {
const { data: residences, isLoading } = useResidences(); const { data: residences, isLoading } = useResidences();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const list = Array.isArray(residences) ? residences : [];
const totalOverdue = 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 = 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 = 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 = 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
+9 -2
View File
@@ -2,12 +2,14 @@
import { use } from "react"; import { use } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton"; import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner"; import { ErrorBanner } from "@/components/shared/error-banner";
import { ResidenceForm } from "@/components/residences/residence-form"; import { ResidenceForm } from "@/components/residences/residence-form";
import { useResidence, useUpdateResidence } from "@/lib/hooks/use-residences"; import { useResidence, useUpdateResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface EditResidencePageProps { interface EditResidencePageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -17,6 +19,7 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
const { id: rawId } = use(params); const { id: rawId } = use(params);
const id = Number(rawId); const id = Number(rawId);
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id); const { data: residence, isLoading, error, refetch } = useResidence(id);
const updateResidence = useUpdateResidence(id); const updateResidence = useUpdateResidence(id);
@@ -41,7 +44,7 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader <PageHeader
title="Edit Residence" title="Edit Residence"
description={residence.name} description={residence.name}
@@ -53,7 +56,11 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
onSubmit={(data) => { onSubmit={(data) => {
updateResidence.mutate(data, { updateResidence.mutate(data, {
onSuccess: () => { 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 { use, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { MapPin, Pencil, Share2, Trash2, FileDown } from "lucide-react"; 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 { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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: rawId } = use(params);
const id = Number(rawId); const id = Number(rawId);
const router = useRouter(); const router = useRouter();
const { basePath, sharing } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id); const { data: residence, isLoading, error, refetch } = useResidence(id);
const { data: residences } = useResidences(); const { data: residences } = useResidences();
@@ -35,17 +37,21 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
setReportLoading(true); setReportLoading(true);
setReportMessage(null); setReportMessage(null);
try { try {
const result = await residencesApi.generateTasksReport(id); const result = await sharing.generateTasksReport(id);
setReportMessage(result.message || "Report sent to your email!"); const msg = result.message || "Report sent to your email!";
setReportMessage(msg);
toast.success(msg);
} catch { } catch {
setReportMessage("Failed to generate report."); setReportMessage("Failed to generate report.");
toast.error("Failed to generate report");
} finally { } finally {
setReportLoading(false); setReportLoading(false);
} }
}; };
// Find the task summary from the residences list // 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; const taskSummary = myResidence?.task_summary;
if (isLoading) { if (isLoading) {
@@ -93,7 +99,7 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => router.push(`/app/residences/${id}/share`)} onClick={() => router.push(`${basePath}/residences/${id}/share`)}
> >
<Share2 className="size-4 mr-2" /> <Share2 className="size-4 mr-2" />
Share Share
@@ -111,7 +117,7 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => router.push(`/app/residences/${id}/edit`)} onClick={() => router.push(`${basePath}/residences/${id}/edit`)}
> >
<Pencil className="size-4 mr-2" /> <Pencil className="size-4 mr-2" />
Edit Edit
@@ -193,7 +199,11 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
onConfirm={() => { onConfirm={() => {
deleteResidence.mutate(id, { deleteResidence.mutate(id, {
onSuccess: () => { 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 { UserManagement } from "@/components/sharing/user-management";
import { CaseraFileExport } from "@/components/sharing/casera-file-handler"; import { CaseraFileExport } from "@/components/sharing/casera-file-handler";
import { useResidence } from "@/lib/hooks/use-residences"; import { useResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface SharePageProps { interface SharePageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -20,6 +21,7 @@ export default function ResidenceSharePage({ params }: SharePageProps) {
const { id: rawId } = use(params); const { id: rawId } = use(params);
const id = Number(rawId); const id = Number(rawId);
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id); const { data: residence, isLoading, error, refetch } = useResidence(id);
@@ -73,7 +75,7 @@ export default function ResidenceSharePage({ params }: SharePageProps) {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => router.push(`/app/residences/${id}`)} onClick={() => router.push(`${basePath}/residences/${id}`)}
> >
<ArrowLeft className="size-4 mr-2" /> <ArrowLeft className="size-4 mr-2" />
Back Back
+13 -2
View File
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Home } from "lucide-react"; import { Home } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 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 { ErrorBanner } from "@/components/shared/error-banner";
import { CaseraFileImport } from "@/components/sharing/casera-file-handler"; import { CaseraFileImport } from "@/components/sharing/casera-file-handler";
import { useJoinResidence } from "@/lib/hooks/use-sharing"; import { useJoinResidence } from "@/lib/hooks/use-sharing";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function JoinResidencePage() { export default function JoinResidencePage() {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const joinResidence = useJoinResidence(); const joinResidence = useJoinResidence();
const [code, setCode] = useState(""); const [code, setCode] = useState("");
@@ -27,7 +30,11 @@ export default function JoinResidencePage() {
joinResidence.mutate(trimmed, { joinResidence.mutate(trimmed, {
onSuccess: () => { 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; const importedCode = (data as Record<string, unknown>).code as string;
joinResidence.mutate(importedCode, { joinResidence.mutate(importedCode, {
onSuccess: () => { onSuccess: () => {
router.push("/app/residences"); toast.success("Joined residence");
router.push(`${basePath}/residences`);
},
onError: () => {
toast.error("Failed to join residence");
}, },
}); });
} else { } else {
+9 -2
View File
@@ -1,17 +1,20 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
import { ResidenceForm } from "@/components/residences/residence-form"; import { ResidenceForm } from "@/components/residences/residence-form";
import { useCreateResidence } from "@/lib/hooks/use-residences"; import { useCreateResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewResidencePage() { export default function NewResidencePage() {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const createResidence = useCreateResidence(); const createResidence = useCreateResidence();
return ( 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" /> <PageHeader title="New Residence" description="Add a new property" />
<ResidenceForm <ResidenceForm
@@ -19,7 +22,11 @@ export default function NewResidencePage() {
onSubmit={(data) => { onSubmit={(data) => {
createResidence.mutate(data, { createResidence.mutate(data, {
onSuccess: (res) => { 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 { EmptyState } from "@/components/shared/empty-state";
import { ResidenceCard } from "@/components/residences/residence-card"; import { ResidenceCard } from "@/components/residences/residence-card";
import { useResidences } from "@/lib/hooks/use-residences"; import { useResidences } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ResidencesPage() { export default function ResidencesPage() {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { data: residences, isLoading, error, refetch } = useResidences(); const { data: residences, isLoading, error, refetch } = useResidences();
return ( return (
@@ -20,7 +22,7 @@ export default function ResidencesPage() {
title="Residences" title="Residences"
description="Manage your properties" description="Manage your properties"
actionLabel="Add Residence" actionLabel="Add Residence"
onAction={() => router.push("/app/residences/new")} onAction={() => router.push(`${basePath}/residences/new`)}
/> />
{isLoading && <LoadingSkeleton variant="card-grid" />} {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 <EmptyState
icon={Home} icon={Home}
title="No residences yet" title="No residences yet"
description="Add your first property to start tracking tasks and maintenance." description="Add your first property to start tracking tasks and maintenance."
actionLabel="Add Residence" 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"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{residences.map((item) => ( {residences.map((item) => (
<ResidenceCard key={item.residence.id} data={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 { usePathname } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { User, Bell, CreditCard } from "lucide-react"; import { User, Bell, CreditCard } from "lucide-react";
import { useDataProvider } from "@/lib/demo/data-provider-context";
const settingsNav = [ function getSettingsNav(basePath: string) {
{ label: "Profile", href: "/app/settings/profile", icon: User }, return [
{ label: "Notifications", href: "/app/settings/notifications", icon: Bell }, { label: "Profile", href: `${basePath}/settings/profile`, icon: User },
{ label: "Subscription", href: "/app/settings/subscription", icon: CreditCard }, { label: "Notifications", href: `${basePath}/settings/notifications`, icon: Bell },
]; { label: "Subscription", href: `${basePath}/settings/subscription`, icon: CreditCard },
];
}
export default function SettingsLayout({ children }: { children: React.ReactNode }) { export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const { basePath } = useDataProvider();
const settingsNav = getSettingsNav(basePath);
return ( return (
<div className="space-y-6"> <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() { 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 { use } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton"; import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner"; import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { TaskCompletionForm } from "@/components/tasks/task-completion-form"; import { TaskCompletionForm } from "@/components/tasks/task-completion-form";
import { useTask, useCreateCompletion } from "@/lib/hooks/use-tasks"; import { useTask, useCreateCompletion } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function CompleteTaskPage({ export default function CompleteTaskPage({
params, params,
@@ -17,6 +19,7 @@ export default function CompleteTaskPage({
const { id } = use(params); const { id } = use(params);
const taskId = Number(id); const taskId = Number(id);
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId); const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const createCompletion = useCreateCompletion(); const createCompletion = useCreateCompletion();
@@ -61,7 +64,13 @@ export default function CompleteTaskPage({
images, 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 { use } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton"; import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner"; import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form"; import { TaskForm } from "@/components/tasks/task-form";
import { useTask, useUpdateTask } from "@/lib/hooks/use-tasks"; import { useTask, useUpdateTask } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function EditTaskPage({ export default function EditTaskPage({
params, params,
@@ -17,6 +19,7 @@ export default function EditTaskPage({
const { id } = use(params); const { id } = use(params);
const taskId = Number(id); const taskId = Number(id);
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId); const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const updateTask = useUpdateTask(taskId); const updateTask = useUpdateTask(taskId);
@@ -39,7 +42,7 @@ export default function EditTaskPage({
if (!task) return null; if (!task) return null;
return ( return (
<div className="space-y-6"> <div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="Edit Task" /> <PageHeader title="Edit Task" />
<Card> <Card>
@@ -48,7 +51,13 @@ export default function EditTaskPage({
task={task} task={task}
onSubmit={(data) => { onSubmit={(data) => {
updateTask.mutate(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} isSubmitting={updateTask.isPending}
+4 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { use } from "react"; import { use } from "react";
import Image from "next/image";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
@@ -233,10 +234,12 @@ export default function TaskDetailPage({
{completion.images.length > 0 && ( {completion.images.length > 0 && (
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{completion.images.map((img) => ( {completion.images.map((img) => (
<img <Image
key={img.id} key={img.id}
src={img.image_url} src={img.image_url}
alt={img.caption || "Completion photo"} alt={img.caption || "Completion photo"}
width={80}
height={80}
className="size-20 rounded-md object-cover border" className="size-20 rounded-md object-cover border"
/> />
))} ))}
+11 -2
View File
@@ -1,17 +1,20 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form"; import { TaskForm } from "@/components/tasks/task-form";
import { useCreateTask } from "@/lib/hooks/use-tasks"; import { useCreateTask } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewTaskPage() { export default function NewTaskPage() {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const createTask = useCreateTask(); const createTask = useCreateTask();
return ( return (
<div className="space-y-6"> <div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Task" /> <PageHeader title="New Task" />
<Card> <Card>
@@ -19,7 +22,13 @@ export default function NewTaskPage() {
<TaskForm <TaskForm
onSubmit={(data) => { onSubmit={(data) => {
createTask.mutate(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} isSubmitting={createTask.isPending}
+11 -4
View File
@@ -2,18 +2,25 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import { ClipboardList } from "lucide-react"; import { ClipboardList } from "lucide-react";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton"; import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner"; import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state"; import { EmptyState } from "@/components/shared/empty-state";
import { LookupSelect } from "@/components/shared/lookup-select"; import { LookupSelect } from "@/components/shared/lookup-select";
import { KanbanBoard } from "@/components/tasks/kanban-board";
import { useTasks, useTasksByResidence } from "@/lib/hooks/use-tasks"; 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 { useResidences } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function TasksPage() { export default function TasksPage() {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const [selectedResidenceId, setSelectedResidenceId] = useState< const [selectedResidenceId, setSelectedResidenceId] = useState<
number | undefined number | undefined
>(); >();
@@ -25,7 +32,7 @@ export default function TasksPage() {
const activeQuery = selectedResidenceId ? filteredTasks : allTasks; const activeQuery = selectedResidenceId ? filteredTasks : allTasks;
const { data, isLoading, isError, error, refetch } = activeQuery; const { data, isLoading, isError, error, refetch } = activeQuery;
const residenceItems = (residences ?? []).map((r) => ({ const residenceItems = (Array.isArray(residences) ? residences : []).map((r) => ({
id: r.residence.id, id: r.residence.id,
name: r.residence.name, name: r.residence.name,
})); }));
@@ -39,7 +46,7 @@ export default function TasksPage() {
title="Tasks" title="Tasks"
description="Manage your home maintenance tasks" description="Manage your home maintenance tasks"
actionLabel="New Task" actionLabel="New Task"
onAction={() => router.push("/app/tasks/new")} onAction={() => router.push(`${basePath}/tasks/new`)}
> >
{residenceItems.length > 1 && ( {residenceItems.length > 1 && (
<LookupSelect <LookupSelect
@@ -68,7 +75,7 @@ export default function TasksPage() {
title="No tasks yet" title="No tasks yet"
description="Create your first task to start tracking home maintenance." description="Create your first task to start tracking home maintenance."
actionLabel="New Task" 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 type { Metadata } from "next";
import { Suspense } from "react";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/lib/themes/theme-provider"; import { ThemeProvider } from "@/lib/themes/theme-provider";
import { QueryProvider } from "@/lib/query/query-provider"; import { QueryProvider } from "@/lib/query/query-provider";
import { PostHogProvider } from "@/lib/analytics/posthog-provider";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -15,8 +18,24 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Casera", title: {
description: "Property management platform", 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({ export default function RootLayout({
@@ -29,9 +48,16 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<QueryProvider> <Suspense fallback={null}>
<ThemeProvider>{children}</ThemeProvider> <PostHogProvider>
</QueryProvider> <QueryProvider>
<ThemeProvider>
{children}
<Toaster richColors closeButton />
</ThemeProvider>
</QueryProvider>
</PostHogProvider>
</Suspense>
</body> </body>
</html> </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 { Card, CardContent, CardHeader, CardTitle, CardDescription, CardAction } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { ContractorResponse } from "@/lib/api/contractors"; import type { ContractorResponse } from "@/lib/api/contractors";
interface ContractorCardProps { interface ContractorCardProps {
@@ -13,10 +14,11 @@ interface ContractorCardProps {
} }
export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) { export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) {
const { basePath } = useDataProvider();
return ( return (
<Card className="transition-shadow hover:shadow-md"> <Card className="transition-shadow hover:shadow-md">
<CardHeader> <CardHeader>
<Link href={`/app/contractors/${contractor.id}`} className="hover:underline"> <Link href={`${basePath}/contractors/${contractor.id}`} className="hover:underline">
<CardTitle>{contractor.name}</CardTitle> <CardTitle>{contractor.name}</CardTitle>
</Link> </Link>
{contractor.company && ( {contractor.company && (
@@ -27,12 +29,14 @@ export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardP
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-8" className="size-8"
aria-label={contractor.is_favorite ? "Remove from favorites" : "Add to favorites"}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onToggleFavorite(contractor.id); onToggleFavorite(contractor.id);
}} }}
> >
<Star <Star
aria-hidden="true"
className={ className={
contractor.is_favorite contractor.is_favorite
? "size-4 fill-yellow-400 text-yellow-400" ? "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"> <div className="flex items-center gap-2">
{contractor.phone && ( {contractor.phone && (
<Button variant="outline" size="icon" className="size-8" asChild> <Button variant="outline" size="icon" className="size-8" asChild>
<a href={`tel:${contractor.phone}`} onClick={(e) => e.stopPropagation()}> <a href={`tel:${contractor.phone}`} aria-label={`Call ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
<Phone className="size-4" /> <Phone className="size-4" aria-hidden="true" />
</a> </a>
</Button> </Button>
)} )}
{contractor.email && ( {contractor.email && (
<Button variant="outline" size="icon" className="size-8" asChild> <Button variant="outline" size="icon" className="size-8" asChild>
<a href={`mailto:${contractor.email}`} onClick={(e) => e.stopPropagation()}> <a href={`mailto:${contractor.email}`} aria-label={`Email ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
<Mail className="size-4" /> <Mail className="size-4" aria-hidden="true" />
</a> </a>
</Button> </Button>
)} )}
@@ -51,7 +51,7 @@ export function ContractorForm({ contractor, onSubmit, loading }: ContractorForm
const { data: specialties } = useContractorSpecialties(); const { data: specialties } = useContractorSpecialties();
const { data: residencesData } = useResidences(); const { data: residencesData } = useResidences();
const residenceItems = (residencesData ?? []).map((r) => ({ const residenceItems = (Array.isArray(residencesData) ? residencesData : []).map((r) => ({
id: r.residence.id, id: r.residence.id,
name: r.residence.name, name: r.residence.name,
})); }));
+3 -1
View File
@@ -5,9 +5,11 @@ import { Bell } from "lucide-react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useNotifications } from "@/lib/hooks/use-notifications"; import { useNotifications } from "@/lib/hooks/use-notifications";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export function RecentActivity() { export function RecentActivity() {
const { data, isLoading } = useNotifications(5); const { data, isLoading } = useNotifications(5);
const { basePath } = useDataProvider();
const notifications = data?.results ?? []; const notifications = data?.results ?? [];
@@ -17,7 +19,7 @@ export function RecentActivity() {
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span>Recent Activity</span> <span>Recent Activity</span>
<Link <Link
href="/app/settings/notifications" href={`${basePath}/settings/notifications`}
className="text-sm font-normal text-primary hover:underline" className="text-sm font-normal text-primary hover:underline"
> >
View all View all
+3 -1
View File
@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { AlertTriangle, Clock, ClipboardList, CheckCircle2 } from "lucide-react"; import { AlertTriangle, Clock, ClipboardList, CheckCircle2 } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface StatsCardsProps { interface StatsCardsProps {
overdue: number; overdue: number;
@@ -44,11 +45,12 @@ const stats = [
export function StatsCards({ overdue, dueSoon, active, completed }: StatsCardsProps) { export function StatsCards({ overdue, dueSoon, active, completed }: StatsCardsProps) {
const values: Record<string, number> = { overdue, dueSoon, active, completed }; const values: Record<string, number> = { overdue, dueSoon, active, completed };
const { basePath } = useDataProvider();
return ( return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat) => ( {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"> <Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-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 Link from "next/link";
import { FileText, FileImage, File, FileSpreadsheet } from "lucide-react"; import { FileText, FileImage, File, FileSpreadsheet } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -5,6 +7,7 @@ import { format } from "date-fns";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { WarrantyStatus } from "@/components/documents/warranty-status"; import { WarrantyStatus } from "@/components/documents/warranty-status";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { DocumentResponse } from "@/lib/api/documents"; import type { DocumentResponse } from "@/lib/api/documents";
interface DocumentCardProps { interface DocumentCardProps {
@@ -29,13 +32,14 @@ const typeLabels: Record<string, string> = {
export function DocumentCard({ document: doc }: DocumentCardProps) { export function DocumentCard({ document: doc }: DocumentCardProps) {
const Icon = getFileIcon(doc.mime_type); const Icon = getFileIcon(doc.mime_type);
const { basePath } = useDataProvider();
return ( 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"> <Card className="transition-colors hover:border-primary/40">
<CardHeader> <CardHeader>
<div className="flex items-start gap-3"> <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" /> <Icon className="size-5 text-muted-foreground" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
+1 -1
View File
@@ -79,7 +79,7 @@ export function DocumentForm({
const { data: residences } = useResidences(); const { data: residences } = useResidences();
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const residenceItems = (residences ?? []).map((r) => ({ const residenceItems = (Array.isArray(residences) ? residences : []).map((r) => ({
id: r.residence.id, id: r.residence.id,
name: r.residence.name, name: r.residence.name,
})); }));
+8 -3
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Image from "next/image";
import { import {
Dialog, Dialog,
DialogContent, 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" 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)} onClick={() => setSelectedImage(image)}
> >
<img <Image
src={image.image_url} src={image.image_url}
alt={image.caption || "Document image"} 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 && ( {image.caption && (
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1"> <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> </DialogHeader>
{selectedImage && ( {selectedImage && (
<div className="flex justify-center"> <div className="flex justify-center">
<img <Image
src={selectedImage.image_url} src={selectedImage.image_url}
alt={selectedImage.caption || "Document image"} alt={selectedImage.caption || "Document image"}
width={800}
height={600}
className="max-h-[70vh] w-auto rounded-md object-contain" className="max-h-[70vh] w-auto rounded-md object-contain"
/> />
</div> </div>
+13 -8
View File
@@ -3,27 +3,32 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { navItems } from './nav-items'; import { getNavItems } from './nav-items';
import { useDataProvider } from '@/lib/demo/data-provider-context';
// Show the first 5 nav items on mobile (exclude Settings)
const mobileNavItems = navItems.filter((item) => item.label !== 'Settings');
export function MobileNav() { export function MobileNav() {
const pathname = usePathname(); 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 ( 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"> <div className="flex items-center justify-around px-2 py-2">
{mobileNavItems.map((item) => { {mobileNavItems.map((item) => {
const isActive = const isActive =
item.href === '/app' item.href === basePath
? pathname === '/app' ? pathname === basePath
: pathname.startsWith(item.href); : pathname.startsWith(item.href);
return ( return (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={cn( className={cn(
'flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors', 'flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors',
isActive isActive
@@ -31,7 +36,7 @@ export function MobileNav() {
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
)} )}
> >
<item.icon className="size-5" /> <item.icon className="size-5" aria-hidden="true" />
<span>{item.label}</span> <span>{item.label}</span>
</Link> </Link>
); );
+13 -8
View File
@@ -6,11 +6,16 @@ export interface NavItem {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
} }
export const navItems: NavItem[] = [ export function getNavItems(basePath: string): NavItem[] {
{ label: 'Home', href: '/app', icon: Home }, return [
{ label: 'Residences', href: '/app/residences', icon: Building2 }, { label: 'Home', href: basePath, icon: Home },
{ label: 'Tasks', href: '/app/tasks', icon: CheckSquare }, { label: 'Residences', href: `${basePath}/residences`, icon: Building2 },
{ label: 'Contractors', href: '/app/contractors', icon: HardHat }, { label: 'Tasks', href: `${basePath}/tasks`, icon: CheckSquare },
{ label: 'Documents', href: '/app/documents', icon: FileText }, { label: 'Contractors', href: `${basePath}/contractors`, icon: HardHat },
{ label: 'Settings', href: '/app/settings', icon: Settings }, { 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 { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator'; 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() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { basePath } = useDataProvider();
const navItems = getNavItems(basePath);
return ( 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"> <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 */} {/* Logo */}
<div className="flex items-center h-16 px-4 lg:px-6"> <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="text-xl font-bold text-primary">C</span>
<span className="hidden lg:inline text-xl font-bold text-foreground"> <span className="hidden lg:inline text-xl font-bold text-foreground">
Casera Casera
@@ -24,17 +27,19 @@ export function Sidebar() {
<Separator /> <Separator />
{/* Navigation */} {/* 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) => { {navItems.map((item) => {
const isActive = const isActive =
item.href === '/app' item.href === basePath
? pathname === '/app' ? pathname === basePath
: pathname.startsWith(item.href); : pathname.startsWith(item.href);
return ( return (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={cn( className={cn(
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors', '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', 'hover:bg-accent hover:text-accent-foreground',
@@ -43,7 +48,7 @@ export function Sidebar() {
: 'text-muted-foreground' : '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> <span className="hidden lg:inline">{item.label}</span>
</Link> </Link>
); );
+10 -4
View File
@@ -11,9 +11,11 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { useDataProvider } from '@/lib/demo/data-provider-context';
export function TopBar() { export function TopBar() {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const handleLogout = async () => { const handleLogout = async () => {
try { try {
@@ -21,7 +23,11 @@ export function TopBar() {
} catch { } catch {
// Continue with redirect even if the API call fails // Continue with redirect even if the API call fails
} }
router.push('/login'); if (basePath.startsWith('/demo')) {
router.push('/demo');
} else {
router.push('/login');
}
}; };
return ( return (
@@ -39,18 +45,18 @@ export function TopBar() {
<NotificationBell /> <NotificationBell />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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> <Avatar>
<AvatarFallback>U</AvatarFallback> <AvatarFallback>U</AvatarFallback>
</Avatar> </Avatar>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => router.push('/app/settings')}> <DropdownMenuItem onClick={() => router.push(`${basePath}/settings`)}>
<User className="size-4" /> <User className="size-4" />
Profile Profile
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/app/settings')}> <DropdownMenuItem onClick={() => router.push(`${basePath}/settings`)}>
<Settings className="size-4" /> <Settings className="size-4" />
Settings Settings
</DropdownMenuItem> </DropdownMenuItem>
@@ -21,10 +21,10 @@ export function NotificationBell() {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative"> <Button variant="ghost" size="icon" className="relative" aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : "Notifications"}>
<Bell className="size-5" /> <Bell className="size-5" aria-hidden="true" />
{unreadCount > 0 && ( {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} {unreadCount > 9 ? "9+" : unreadCount}
</span> </span>
)} )}
+4 -2
View File
@@ -6,9 +6,11 @@ import { CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useOnboardingStore } from "@/stores/onboarding"; import { useOnboardingStore } from "@/stores/onboarding";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export function CompleteStep() { export function CompleteStep() {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const { path, residenceId, complete } = useOnboardingStore(); const { path, residenceId, complete } = useOnboardingStore();
useEffect(() => { useEffect(() => {
@@ -20,9 +22,9 @@ export function CompleteStep() {
const handleNavigate = () => { const handleNavigate = () => {
if (isCreatePath && residenceId) { if (isCreatePath && residenceId) {
router.push(`/app/residences/${residenceId}`); router.push(`${basePath}/residences/${residenceId}`);
} else { } 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 Link from "next/link";
import { MapPin } from "lucide-react"; import { MapPin } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { MyResidenceResponse } from "@/lib/api/residences"; import type { MyResidenceResponse } from "@/lib/api/residences";
interface ResidenceCardProps { interface ResidenceCardProps {
@@ -11,19 +14,21 @@ interface ResidenceCardProps {
export function ResidenceCard({ data }: ResidenceCardProps) { export function ResidenceCard({ data }: ResidenceCardProps) {
const { residence, task_summary } = data; const { residence, task_summary } = data;
const { basePath } = useDataProvider();
const address = [residence.street_address, residence.city, residence.state_province] const address = [residence.street_address, residence.city, residence.state_province]
.filter(Boolean) .filter(Boolean)
.join(", "); .join(", ");
return ( 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"> <Card className="transition-colors hover:border-primary/40">
<CardHeader> <CardHeader>
<CardTitle className="text-base">{residence.name}</CardTitle> <CardTitle className="text-base">{residence.name}</CardTitle>
{address && ( {address && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> <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> <span className="truncate">{address}</span>
</div> </div>
)} )}
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { toast } from "sonner";
import { Loader2, Check } from "lucide-react"; import { Loader2, Check } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -48,10 +49,12 @@ export function ChangePasswordForm() {
}); });
reset(); reset();
setSuccess(true); setSuccess(true);
toast.success("Password changed");
} catch (err) { } catch (err) {
const message = const message =
err instanceof Error ? err.message : "Failed to change password."; err instanceof Error ? err.message : "Failed to change password.";
setApiError(message); setApiError(message);
toast.error("Failed to change password");
} }
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -37,6 +38,7 @@ export function DeleteAccountSection() {
const message = const message =
err instanceof Error ? err.message : "Failed to delete account."; err instanceof Error ? err.message : "Failed to delete account.";
setApiError(message); setApiError(message);
toast.error("Failed to delete account");
setIsDeleting(false); setIsDeleting(false);
} }
} }
@@ -1,5 +1,6 @@
"use client"; "use client";
import { toast } from "sonner";
import { useNotificationPreferences, useUpdatePreferences } from "@/lib/hooks/use-notifications"; import { useNotificationPreferences, useUpdatePreferences } from "@/lib/hooks/use-notifications";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -45,7 +46,14 @@ export function NotificationPreferences() {
function handleToggle(key: keyof NotificationPreferencesResponse, checked: boolean) { function handleToggle(key: keyof NotificationPreferencesResponse, checked: boolean) {
const update: UpdatePreferencesRequest = { [key]: checked }; 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) { if (isLoading) {
+3
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { toast } from "sonner";
import { Loader2, Check } from "lucide-react"; import { Loader2, Check } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -46,10 +47,12 @@ export function ProfileForm() {
await authApi.updateProfile(data); await authApi.updateProfile(data);
await fetchUser(); await fetchUser();
setSuccess(true); setSuccess(true);
toast.success("Profile updated");
} catch (err) { } catch (err) {
const message = const message =
err instanceof Error ? err.message : "Failed to update profile."; err instanceof Error ? err.message : "Failed to update profile.";
setApiError(message); 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) { export function ErrorBanner({ message = "Something went wrong. Please try again.", onRetry }: ErrorBannerProps) {
return ( return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 flex items-center gap-3"> <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" /> <AlertTriangle className="size-5 text-destructive shrink-0" aria-hidden="true" />
<p className="text-sm text-destructive flex-1">{message}</p> <p className="text-sm text-destructive flex-1">{message}</p>
{onRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>} {onRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}
</div> </div>
+3 -2
View File
@@ -11,11 +11,12 @@ interface FormFieldProps {
} }
export function FormField({ label, htmlFor, error, required, className, children }: FormFieldProps) { export function FormField({ label, htmlFor, error, required, className, children }: FormFieldProps) {
const errorId = error ? `${htmlFor}-error` : undefined;
return ( return (
<div className={cn("space-y-2", className)}> <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} {children}
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p id={errorId} role="alert" className="text-sm text-destructive">{error}</p>}
</div> </div>
); );
} }
+10 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { Copy, Check, RefreshCw } from "lucide-react"; import { Copy, Check, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -21,11 +22,19 @@ export function ShareCodeDisplay({ residenceId }: ShareCodeDisplayProps) {
if (!shareCode) return; if (!shareCode) return;
await navigator.clipboard.writeText(shareCode.code); await navigator.clipboard.writeText(shareCode.code);
setCopied(true); setCopied(true);
toast.success("Code copied to clipboard");
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} }
function handleGenerate() { function handleGenerate() {
generateCode.mutate(); generateCode.mutate(undefined, {
onSuccess: () => {
toast.success("Share code generated");
},
onError: () => {
toast.error("Failed to generate share code");
},
});
} }
if (isLoading) { if (isLoading) {
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { UserMinus } from "lucide-react"; import { UserMinus } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -24,8 +25,12 @@ export function UserManagement({ residenceId }: UserManagementProps) {
if (!removeTarget) return; if (!removeTarget) return;
removeUser.mutate(removeTarget.id, { removeUser.mutate(removeTarget.id, {
onSuccess: () => { onSuccess: () => {
toast.success("Member removed");
setRemoveTarget(null); setRemoveTarget(null);
}, },
onError: () => {
toast.error("Failed to remove member");
},
}); });
} }
+69 -10
View File
@@ -1,17 +1,20 @@
"use client"; "use client";
import { useCallback } from "react"; import { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { import {
DndContext, DndContext,
PointerSensor, PointerSensor,
pointerWithin,
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { useMarkInProgress } from "@/lib/hooks/use-tasks"; import { useMarkInProgress } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import { KanbanColumn } from "./kanban-column"; import { KanbanColumn } from "./kanban-column";
import type { KanbanResponse } from "@/lib/api/tasks"; import type { KanbanResponse, KanbanColumn as KanbanColumnType } from "@/lib/api/tasks";
interface KanbanBoardProps { interface KanbanBoardProps {
data: KanbanResponse; data: KanbanResponse;
@@ -19,14 +22,47 @@ interface KanbanBoardProps {
export function KanbanBoard({ data }: KanbanBoardProps) { export function KanbanBoard({ data }: KanbanBoardProps) {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const { basePath } = useDataProvider();
const markInProgress = useMarkInProgress(); 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( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, 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( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
@@ -35,19 +71,42 @@ export function KanbanBoard({ data }: KanbanBoardProps) {
const taskId = active.id as number; const taskId = active.id as number;
const targetColumn = over.id as string; const targetColumn = over.id as string;
if (targetColumn === "in_progress") { // Find source column
markInProgress.mutate(taskId); const sourceCol = columns.find((col) =>
} else if (targetColumn === "completed") { col.tasks.some((t) => t.id === taskId)
router.push(`/app/tasks/${taskId}/complete`); );
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 ( return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={pointerWithin} onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4"> <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">
{data.columns.map((column) => ( {columns.map((column) => (
<KanbanColumn key={column.name} column={column} /> <KanbanColumn key={column.name} column={column} />
))} ))}
</div> </div>
+57 -37
View File
@@ -1,62 +1,86 @@
"use client"; "use client";
import { useDroppable } from "@dnd-kit/core"; import { useRef, useEffect } from "react";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; import { useDraggable, useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TaskCard } from "./task-card"; import { TaskCard } from "./task-card";
import type { KanbanColumn as KanbanColumnType } from "@/lib/api/tasks"; import type { KanbanColumn as KanbanColumnType } from "@/lib/api/tasks";
const COLUMN_COLORS: Record<string, string> = { const COLUMN_COLORS: Record<string, string> = {
overdue: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20", overdue_tasks: "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_tasks: "border-yellow-500/50 bg-yellow-50/50 dark:bg-yellow-950/20",
due_soon: "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",
upcoming: "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",
in_progress: "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",
completed: "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> = { const COLUMN_HEADER_COLORS: Record<string, string> = {
overdue: "text-red-700 dark:text-red-400", overdue_tasks: "text-red-700 dark:text-red-400",
due_today: "text-orange-700 dark:text-orange-400", due_soon_tasks: "text-yellow-700 dark:text-yellow-400",
due_soon: "text-yellow-700 dark:text-yellow-400", upcoming_tasks: "text-blue-700 dark:text-blue-400",
upcoming: "text-blue-700 dark:text-blue-400", in_progress_tasks: "text-green-700 dark:text-green-400",
in_progress: "text-green-700 dark:text-green-400", completed_tasks: "text-gray-700 dark:text-gray-400",
completed: "text-gray-700 dark:text-gray-400", cancelled_tasks: "text-slate-700 dark:text-slate-400",
}; };
const COUNT_BADGE_COLORS: Record<string, string> = { const COUNT_BADGE_COLORS: Record<string, string> = {
overdue: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", overdue_tasks: "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_tasks: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
due_soon: "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",
upcoming: "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",
in_progress: "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",
completed: "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 { interface KanbanColumnProps {
column: KanbanColumnType; column: KanbanColumnType;
} }
function SortableTask({ task }: { task: import("@/lib/api/tasks").TaskResponse }) { function DraggableTask({ task }: { task: import("@/lib/api/tasks").TaskResponse }) {
const { const {
attributes, attributes,
listeners, listeners,
setNodeRef, setNodeRef,
transform, transform,
transition,
isDragging, isDragging,
} = useSortable({ id: task.id }); } = useDraggable({ id: task.id });
const style: React.CSSProperties = { const wasDragging = useRef(false);
transform: CSS.Transform.toString(transform),
transition, useEffect(() => {
opacity: isDragging ? 0.5 : 1, 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 ( return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}> <div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClickCapture={handleClick}
>
<TaskCard task={task} isDragging={isDragging} /> <TaskCard task={task} isDragging={isDragging} />
</div> </div>
); );
@@ -67,12 +91,10 @@ export function KanbanColumn({ column }: KanbanColumnProps) {
id: column.name, id: column.name,
}); });
const taskIds = column.tasks.map((t) => t.id);
return ( return (
<div <div
className={cn( 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", COLUMN_COLORS[column.name] ?? "border-border bg-muted/30",
isOver && "ring-2 ring-primary" isOver && "ring-2 ring-primary"
)} )}
@@ -95,11 +117,9 @@ export function KanbanColumn({ column }: KanbanColumnProps) {
</div> </div>
<div ref={setNodeRef} className="flex-1 space-y-2 min-h-[60px]"> <div ref={setNodeRef} className="flex-1 space-y-2 min-h-[60px]">
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}> {column.tasks.map((task) => (
{column.tasks.map((task) => ( <DraggableTask key={task.id} task={task} />
<SortableTask key={task.id} task={task} /> ))}
))}
</SortableContext>
{column.tasks.length === 0 && ( {column.tasks.length === 0 && (
<div className="flex items-center justify-center h-[60px] text-xs text-muted-foreground rounded-md border border-dashed"> <div className="flex items-center justify-center h-[60px] text-xs text-muted-foreground rounded-md border border-dashed">
No tasks No tasks
+34 -8
View File
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -26,6 +27,7 @@ import {
useArchiveTask, useArchiveTask,
useDeleteTask, useDeleteTask,
} from "@/lib/hooks/use-tasks"; } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface TaskActionsMenuProps { interface TaskActionsMenuProps {
taskId: number; taskId: number;
@@ -33,6 +35,7 @@ interface TaskActionsMenuProps {
export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) { export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) {
const router = useRouter(); const router = useRouter();
const { basePath } = useDataProvider();
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const markInProgress = useMarkInProgress(); const markInProgress = useMarkInProgress();
@@ -44,35 +47,54 @@ export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) {
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="icon"> <Button variant="outline" size="icon" aria-label="Task actions">
<MoreVertical className="size-4" /> <MoreVertical className="size-4" aria-hidden="true" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
onClick={() => router.push(`/app/tasks/${taskId}/complete`)} onClick={() => router.push(`${basePath}/tasks/${taskId}/complete`)}
> >
<CheckCircle className="size-4" /> <CheckCircle className="size-4" />
Complete Complete
</DropdownMenuItem> </DropdownMenuItem>
<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" /> <Play className="size-4" />
Mark In Progress Mark In Progress
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => router.push(`/app/tasks/${taskId}/edit`)} onClick={() => router.push(`${basePath}/tasks/${taskId}/edit`)}
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <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" /> <XCircle className="size-4" />
Cancel Cancel
</DropdownMenuItem> </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 className="size-4" />
Archive Archive
</DropdownMenuItem> </DropdownMenuItem>
@@ -98,8 +120,12 @@ export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) {
onConfirm={() => { onConfirm={() => {
deleteTask.mutate(taskId, { deleteTask.mutate(taskId, {
onSuccess: () => { onSuccess: () => {
toast.success("Task deleted");
setDeleteOpen(false); 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 { Badge } from "@/components/ui/badge";
import { Calendar, DollarSign } from "lucide-react"; import { Calendar, DollarSign } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { TaskResponse } from "@/lib/api/tasks"; import type { TaskResponse } from "@/lib/api/tasks";
interface TaskCardProps { interface TaskCardProps {
@@ -12,8 +13,9 @@ interface TaskCardProps {
} }
export function TaskCard({ task, isDragging }: TaskCardProps) { export function TaskCard({ task, isDragging }: TaskCardProps) {
const { basePath } = useDataProvider();
return ( return (
<Link href={`/app/tasks/${task.id}`}> <Link href={`${basePath}/tasks/${task.id}`}>
<div <div
className={cn( className={cn(
"rounded-lg border bg-card p-3 space-y-2 transition-shadow hover:shadow-md cursor-grab", "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"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
{task.due_date && ( {task.due_date && (
<span className="flex items-center gap-1"> <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()} {new Date(task.due_date).toLocaleDateString()}
</span> </span>
)} )}
{task.estimated_cost != null && task.estimated_cost > 0 && ( {task.estimated_cost != null && task.estimated_cost > 0 && (
<span className="flex items-center gap-1"> <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)} {task.estimated_cost.toFixed(2)}
</span> </span>
)} )}
+2 -2
View File
@@ -49,12 +49,12 @@ export function TaskForm({ task, onSubmit, isSubmitting }: TaskFormProps) {
const { data: priorities } = useTaskPriorities(); const { data: priorities } = useTaskPriorities();
const { data: frequencies } = useTaskFrequencies(); const { data: frequencies } = useTaskFrequencies();
const residenceItems = (residences ?? []).map((r) => ({ const residenceItems = (Array.isArray(residences) ? residences : []).map((r) => ({
id: r.residence.id, id: r.residence.id,
name: r.residence.name, name: r.residence.name,
})); }));
const contractorItems = (contractors ?? []).map((c) => ({ const contractorItems = (Array.isArray(contractors) ? contractors : []).map((c) => ({
id: c.id, id: c.id,
name: c.company ? `${c.name} (${c.company})` : c.name, name: c.company ? `${c.name} (${c.company})` : c.name,
})); }));
+1 -1
View File
@@ -61,7 +61,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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 className
)} )}
{...props} {...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