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:
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
|
.claude
|
||||||
|
tests
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
@@ -39,3 +39,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# claude
|
||||||
|
.claude/
|
||||||
|
|||||||
+34
@@ -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
@@ -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);
|
||||||
|
|||||||
Generated
+650
-8
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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('/')}`;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { default } from "@/app/app/documents/[id]/page";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { default } from "@/app/app/documents/new/page";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { default } from "@/app/app/documents/page";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { default } from "@/app/app/residences/new/page";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { default } from "@/app/app/residences/page";
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { default } from "@/app/app/tasks/[id]/page";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { default } from "@/app/app/tasks/new/page";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use client";
|
||||||
|
export { default } from "@/app/app/tasks/page";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 — 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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're looking for doesn'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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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,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">
|
||||||
|
|||||||
@@ -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're exploring Casera in demo mode. Changes aren'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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
Reference in New Issue
Block a user