From fd30a5f2d192151dd4fe55a1f31661456b2a6f08 Mon Sep 17 00:00:00 2001 From: sabo Date: Sun, 28 Jun 2026 14:16:11 +0300 Subject: [PATCH] Initial digitCRM project with backend and frontend --- .gitignore | 22 + DIGITCRM_SETUP.md | 181 ++++ README.md | 159 ++++ docker-compose.yml | 60 ++ frontend/client/index.html | 26 + frontend/client/public/.gitkeep | 0 .../public/__manus__/debug-collector.js | 821 ++++++++++++++++++ frontend/client/public/__manus__/version.json | 4 + frontend/client/src/App.tsx | 58 ++ .../client/src/components/DashboardLayout.tsx | 136 +++ .../client/src/components/ErrorBoundary.tsx | 62 ++ .../client/src/components/ManusDialog.tsx | 85 ++ frontend/client/src/components/Map.tsx | 155 ++++ .../client/src/components/ui/accordion.tsx | 64 ++ .../client/src/components/ui/alert-dialog.tsx | 155 ++++ frontend/client/src/components/ui/alert.tsx | 66 ++ .../client/src/components/ui/aspect-ratio.tsx | 9 + frontend/client/src/components/ui/avatar.tsx | 51 ++ frontend/client/src/components/ui/badge.tsx | 46 + .../client/src/components/ui/breadcrumb.tsx | 109 +++ .../client/src/components/ui/button-group.tsx | 83 ++ frontend/client/src/components/ui/button.tsx | 60 ++ .../client/src/components/ui/calendar.tsx | 211 +++++ frontend/client/src/components/ui/card.tsx | 92 ++ .../client/src/components/ui/carousel.tsx | 239 +++++ frontend/client/src/components/ui/chart.tsx | 355 ++++++++ .../client/src/components/ui/checkbox.tsx | 30 + .../client/src/components/ui/collapsible.tsx | 31 + frontend/client/src/components/ui/command.tsx | 184 ++++ .../client/src/components/ui/context-menu.tsx | 250 ++++++ frontend/client/src/components/ui/dialog.tsx | 209 +++++ frontend/client/src/components/ui/drawer.tsx | 133 +++ .../src/components/ui/dropdown-menu.tsx | 255 ++++++ frontend/client/src/components/ui/empty.tsx | 104 +++ frontend/client/src/components/ui/field.tsx | 242 ++++++ frontend/client/src/components/ui/form.tsx | 168 ++++ .../client/src/components/ui/hover-card.tsx | 42 + .../client/src/components/ui/input-group.tsx | 168 ++++ .../client/src/components/ui/input-otp.tsx | 75 ++ frontend/client/src/components/ui/input.tsx | 70 ++ frontend/client/src/components/ui/item.tsx | 193 ++++ frontend/client/src/components/ui/kbd.tsx | 28 + frontend/client/src/components/ui/label.tsx | 22 + frontend/client/src/components/ui/menubar.tsx | 274 ++++++ .../src/components/ui/navigation-menu.tsx | 168 ++++ .../client/src/components/ui/pagination.tsx | 127 +++ frontend/client/src/components/ui/popover.tsx | 46 + .../client/src/components/ui/progress.tsx | 29 + .../client/src/components/ui/radio-group.tsx | 43 + .../client/src/components/ui/resizable.tsx | 54 ++ .../client/src/components/ui/scroll-area.tsx | 56 ++ frontend/client/src/components/ui/select.tsx | 185 ++++ .../client/src/components/ui/separator.tsx | 26 + frontend/client/src/components/ui/sheet.tsx | 139 +++ frontend/client/src/components/ui/sidebar.tsx | 734 ++++++++++++++++ .../client/src/components/ui/skeleton.tsx | 13 + frontend/client/src/components/ui/slider.tsx | 61 ++ frontend/client/src/components/ui/sonner.tsx | 23 + frontend/client/src/components/ui/spinner.tsx | 16 + frontend/client/src/components/ui/switch.tsx | 29 + frontend/client/src/components/ui/table.tsx | 114 +++ frontend/client/src/components/ui/tabs.tsx | 64 ++ .../client/src/components/ui/textarea.tsx | 67 ++ .../client/src/components/ui/toggle-group.tsx | 73 ++ frontend/client/src/components/ui/toggle.tsx | 45 + frontend/client/src/components/ui/tooltip.tsx | 59 ++ frontend/client/src/const.ts | 17 + frontend/client/src/contexts/ThemeContext.tsx | 64 ++ frontend/client/src/hooks/useComposition.ts | 81 ++ frontend/client/src/hooks/useMobile.tsx | 21 + frontend/client/src/hooks/usePersistFn.ts | 20 + frontend/client/src/index.css | 177 ++++ frontend/client/src/lib/api.ts | 120 +++ frontend/client/src/lib/utils.ts | 6 + frontend/client/src/main.tsx | 5 + frontend/client/src/pages/Analytics.tsx | 192 ++++ frontend/client/src/pages/Campaigns.tsx | 206 +++++ frontend/client/src/pages/Contacts.tsx | 290 +++++++ frontend/client/src/pages/Dashboard.tsx | 155 ++++ frontend/client/src/pages/Feedback.tsx | 289 ++++++ frontend/client/src/pages/Home.tsx | 25 + frontend/client/src/pages/Login.tsx | 145 ++++ frontend/client/src/pages/NotFound.tsx | 49 ++ frontend/client/src/pages/Tickets.tsx | 311 +++++++ frontend/client/src/pages/Warranties.tsx | 304 +++++++ frontend/components.json | 19 + frontend/package.json | 100 +++ frontend/patches/wouter@3.7.1.patch | 28 + frontend/server/index.ts | 33 + frontend/shared/const.ts | 2 + frontend/template.json | 16 + frontend/tsconfig.json | 23 + frontend/tsconfig.node.json | 22 + frontend/vite.config.ts | 241 +++++ 94 files changed, 10919 insertions(+) create mode 100644 .gitignore create mode 100644 DIGITCRM_SETUP.md create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 frontend/client/index.html create mode 100644 frontend/client/public/.gitkeep create mode 100644 frontend/client/public/__manus__/debug-collector.js create mode 100644 frontend/client/public/__manus__/version.json create mode 100644 frontend/client/src/App.tsx create mode 100644 frontend/client/src/components/DashboardLayout.tsx create mode 100644 frontend/client/src/components/ErrorBoundary.tsx create mode 100644 frontend/client/src/components/ManusDialog.tsx create mode 100644 frontend/client/src/components/Map.tsx create mode 100644 frontend/client/src/components/ui/accordion.tsx create mode 100644 frontend/client/src/components/ui/alert-dialog.tsx create mode 100644 frontend/client/src/components/ui/alert.tsx create mode 100644 frontend/client/src/components/ui/aspect-ratio.tsx create mode 100644 frontend/client/src/components/ui/avatar.tsx create mode 100644 frontend/client/src/components/ui/badge.tsx create mode 100644 frontend/client/src/components/ui/breadcrumb.tsx create mode 100644 frontend/client/src/components/ui/button-group.tsx create mode 100644 frontend/client/src/components/ui/button.tsx create mode 100644 frontend/client/src/components/ui/calendar.tsx create mode 100644 frontend/client/src/components/ui/card.tsx create mode 100644 frontend/client/src/components/ui/carousel.tsx create mode 100644 frontend/client/src/components/ui/chart.tsx create mode 100644 frontend/client/src/components/ui/checkbox.tsx create mode 100644 frontend/client/src/components/ui/collapsible.tsx create mode 100644 frontend/client/src/components/ui/command.tsx create mode 100644 frontend/client/src/components/ui/context-menu.tsx create mode 100644 frontend/client/src/components/ui/dialog.tsx create mode 100644 frontend/client/src/components/ui/drawer.tsx create mode 100644 frontend/client/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/client/src/components/ui/empty.tsx create mode 100644 frontend/client/src/components/ui/field.tsx create mode 100644 frontend/client/src/components/ui/form.tsx create mode 100644 frontend/client/src/components/ui/hover-card.tsx create mode 100644 frontend/client/src/components/ui/input-group.tsx create mode 100644 frontend/client/src/components/ui/input-otp.tsx create mode 100644 frontend/client/src/components/ui/input.tsx create mode 100644 frontend/client/src/components/ui/item.tsx create mode 100644 frontend/client/src/components/ui/kbd.tsx create mode 100644 frontend/client/src/components/ui/label.tsx create mode 100644 frontend/client/src/components/ui/menubar.tsx create mode 100644 frontend/client/src/components/ui/navigation-menu.tsx create mode 100644 frontend/client/src/components/ui/pagination.tsx create mode 100644 frontend/client/src/components/ui/popover.tsx create mode 100644 frontend/client/src/components/ui/progress.tsx create mode 100644 frontend/client/src/components/ui/radio-group.tsx create mode 100644 frontend/client/src/components/ui/resizable.tsx create mode 100644 frontend/client/src/components/ui/scroll-area.tsx create mode 100644 frontend/client/src/components/ui/select.tsx create mode 100644 frontend/client/src/components/ui/separator.tsx create mode 100644 frontend/client/src/components/ui/sheet.tsx create mode 100644 frontend/client/src/components/ui/sidebar.tsx create mode 100644 frontend/client/src/components/ui/skeleton.tsx create mode 100644 frontend/client/src/components/ui/slider.tsx create mode 100644 frontend/client/src/components/ui/sonner.tsx create mode 100644 frontend/client/src/components/ui/spinner.tsx create mode 100644 frontend/client/src/components/ui/switch.tsx create mode 100644 frontend/client/src/components/ui/table.tsx create mode 100644 frontend/client/src/components/ui/tabs.tsx create mode 100644 frontend/client/src/components/ui/textarea.tsx create mode 100644 frontend/client/src/components/ui/toggle-group.tsx create mode 100644 frontend/client/src/components/ui/toggle.tsx create mode 100644 frontend/client/src/components/ui/tooltip.tsx create mode 100644 frontend/client/src/const.ts create mode 100644 frontend/client/src/contexts/ThemeContext.tsx create mode 100644 frontend/client/src/hooks/useComposition.ts create mode 100644 frontend/client/src/hooks/useMobile.tsx create mode 100644 frontend/client/src/hooks/usePersistFn.ts create mode 100644 frontend/client/src/index.css create mode 100644 frontend/client/src/lib/api.ts create mode 100644 frontend/client/src/lib/utils.ts create mode 100644 frontend/client/src/main.tsx create mode 100644 frontend/client/src/pages/Analytics.tsx create mode 100644 frontend/client/src/pages/Campaigns.tsx create mode 100644 frontend/client/src/pages/Contacts.tsx create mode 100644 frontend/client/src/pages/Dashboard.tsx create mode 100644 frontend/client/src/pages/Feedback.tsx create mode 100644 frontend/client/src/pages/Home.tsx create mode 100644 frontend/client/src/pages/Login.tsx create mode 100644 frontend/client/src/pages/NotFound.tsx create mode 100644 frontend/client/src/pages/Tickets.tsx create mode 100644 frontend/client/src/pages/Warranties.tsx create mode 100644 frontend/components.json create mode 100644 frontend/package.json create mode 100644 frontend/patches/wouter@3.7.1.patch create mode 100644 frontend/server/index.ts create mode 100644 frontend/shared/const.ts create mode 100644 frontend/template.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77e65c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +node_modules/ +dist/ +build/ +.env +.env.local +.env.*.local +*.log +.DS_Store +.vscode/ +.idea/ +*.swp +*.swo +*~ +.cache/ +.next/ +out/ +.turbo/ +pnpm-lock.yaml +package-lock.json +yarn.lock +.manus-logs/ +.manus/ diff --git a/DIGITCRM_SETUP.md b/DIGITCRM_SETUP.md new file mode 100644 index 0000000..b16d977 --- /dev/null +++ b/DIGITCRM_SETUP.md @@ -0,0 +1,181 @@ +# digitCRM - Setup Instructions + +## Стъпка 1: Распакуване + +```bash +unzip digitCRM.zip +cd digitCRM +``` + +## Стъпка 2: Инициализирайте Git репозиторий + +```bash +git init +git config user.email "sabo@inex-project.net" +git config user.name "Sabo" +git add -A +git commit -m "Initial digitCRM project with backend and frontend" +``` + +## Стъпка 3: Добавете remote и push-нете + +```bash +git remote add origin https://git.inex-project.net/sabo/digitCRM.git +git branch -M main +git push -u origin main +``` + +**Когато ви поиска пароля, вставете вашия Personal Access Token.** + +## Стъпка 4: Проверете на Git + +Отидете на: https://git.inex-project.net/sabo/digitCRM + +Трябва да видите: +- `backend/` папка +- `frontend/` папка +- `docker-compose.yml` +- `README.md` + +## Стъпка 5: Деплойване на Coolify + +### Опция 1: Docker Compose (препоръчано) + +1. Отворете Coolify +2. Натиснете "New Resource" → "Docker Compose" +3. В "Source" изберете "Git" +4. Въведете: `https://git.inex-project.net/sabo/digitCRM.git` +5. Branch: `main` +6. Docker Compose File: `docker-compose.yml` +7. Натиснете "Deploy" + +### Опция 2: Отделни контейнери + +#### Backend +1. "New Resource" → "Docker" +2. Source: "Git" +3. Repository: `https://git.inex-project.net/sabo/digitCRM.git` +4. Branch: `main` +5. Dockerfile: `backend/Dockerfile` (ако го има) +6. Port: `3001` +7. Environment: + - `NODE_ENV=production` + - `PORT=3000` + - `DB_SERVER=cw.inex-project.net` + - `DB_PORT=14333` + - `DB_USER=inex` + - `DB_PASSWORD=inex123!@#` + - `JWT_SECRET=your-secret-key` + - `CORS_ORIGIN=http://localhost:3000` + +#### Frontend +1. "New Resource" → "Docker" +2. Source: "Git" +3. Repository: `https://git.inex-project.net/sabo/digitCRM.git` +4. Branch: `main` +5. Dockerfile: `frontend/Dockerfile` (ако го има) +6. Port: `3000` +7. Environment: + - `VITE_API_URL=http://localhost:3001/api` + +## Локално тестване + +### Backend +```bash +cd backend +npm install +npm start +``` + +Ще слуша на `http://localhost:3000` + +### Frontend +```bash +cd frontend +pnpm install +pnpm run dev +``` + +Ще слуша на `http://localhost:5173` + +## Структура + +``` +digitCRM/ +├── backend/ +│ ├── src/ +│ ├── routes/ +│ ├── controllers/ +│ ├── models/ +│ ├── package.json +│ └── ... +├── frontend/ +│ ├── client/ +│ │ ├── src/ +│ │ │ ├── pages/ +│ │ │ ├── components/ +│ │ │ ├── lib/ +│ │ │ └── ... +│ │ └── index.html +│ ├── package.json +│ ├── vite.config.ts +│ └── ... +├── docker-compose.yml +├── .gitignore +└── README.md +``` + +## Тестване + +### Регистрация +```bash +curl -X POST http://localhost:3000/api/users/register \ + -H "Content-Type: application/json" \ + -d '{ + "firstName": "John", + "lastName": "Doe", + "email": "john@example.com", + "username": "johndoe", + "password": "Test123!@#" + }' +``` + +### Логин +```bash +curl -X POST http://localhost:3000/api/users/login \ + -H "Content-Type: application/json" \ + -d '{ + "username": "johndoe", + "password": "Test123!@#" + }' +``` + +## Проблеми и решения + +### Git push не работи +Използвайте SSH вместо HTTPS: +```bash +git remote set-url origin git@git.inex-project.net:sabo/digitCRM.git +git push -u origin main +``` + +### Coolify не може да build-ва +Убедете се че: +1. Git репозиторият е публичен или имате достъп +2. Docker е инсталиран на машината +3. Има достатъчно място на диска + +### Frontend не се свързва с backend +Проверете: +1. Backend е работещ на `http://localhost:3001` +2. CORS е конфигуриран правилно +3. `VITE_API_URL` е правилен + +## Следващи стъпки + +1. ✅ Качване на Git +2. ✅ Деплойване на Coolify +3. Тестване на всички функции +4. Конфигуриране на email (SendGrid/Mailgun) +5. Добавяне на SMS функционалност +6. Production deployment diff --git a/README.md b/README.md new file mode 100644 index 0000000..61424be --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# digitCRM - Unified CRM System + +Пълна CRM система с backend и frontend, готова за production. + +## Структура на проекта + +``` +digitCRM/ +├── backend/ # Node.js + Express API +├── frontend/ # React + Vite SPA +├── docker-compose.yml +└── README.md +``` + +## Технологии + +### Backend +- Node.js 18+ +- Express.js +- SQL Server (MSSQL) +- JWT Authentication + +### Frontend +- React 19 +- Vite +- Tailwind CSS +- shadcn/ui компоненти + +## Бързо стартиране + +### 1. Backend + +```bash +cd backend +npm install +npm start +``` + +Server ще слуша на `http://localhost:3001` + +### 2. Frontend + +```bash +cd frontend +pnpm install +pnpm run dev +``` + +App ще слуша на `http://localhost:3000` + +## Environment Variables + +### Backend (.env) +``` +NODE_ENV=production +PORT=3000 +DB_SERVER=cw.inex-project.net +DB_PORT=14333 +DB_USER=inex +DB_PASSWORD=inex123!@# +JWT_SECRET=your-secret-key +CORS_ORIGIN=http://localhost:3000 +``` + +### Frontend (.env) +``` +VITE_API_URL=http://localhost:3001/api +``` + +## Деплойване на Coolify + +1. Отворете Coolify +2. Натиснете "New Resource" → "Docker Compose" +3. В "Source" изберете "Git" +4. Въведете: `https://git.inex-project.net/sabo/digitCRM.git` +5. Branch: `main` +6. Docker Compose File: `docker-compose.yml` +7. Deploy + +## API Endpoints + +### Authentication +- `POST /api/users/register` - Регистрация +- `POST /api/users/login` - Логин + +### Contacts +- `GET /api/contacts` - Всички контакти +- `POST /api/contacts` - Нов контакт +- `GET /api/contacts/:id` - Детайли на контакт +- `PUT /api/contacts/:id` - Актуализирай контакт +- `DELETE /api/contacts/:id` - Изтрий контакт + +### Campaigns +- `GET /api/campaigns` - Всички кампании +- `POST /api/campaigns` - Нова кампания +- `GET /api/campaigns/:id` - Детайли на кампания +- `PUT /api/campaigns/:id` - Актуализирай кампания +- `DELETE /api/campaigns/:id` - Изтрий кампания + +### Tickets +- `GET /api/tickets` - Всички билети +- `POST /api/tickets` - Нов билет +- `GET /api/tickets/:id` - Детайли на билет +- `PUT /api/tickets/:id` - Актуализирай билет +- `DELETE /api/tickets/:id` - Изтрий билет + +### Warranties +- `GET /api/warranties` - Всички гаранции +- `POST /api/warranties` - Нова гаранция +- `GET /api/warranties/:id` - Детайли на гаранция +- `PUT /api/warranties/:id` - Актуализирай гаранция +- `DELETE /api/warranties/:id` - Изтрий гаранция + +### Feedback +- `GET /api/feedback` - Всички обратни връзки +- `POST /api/feedback` - Нова обратна връзка +- `GET /api/feedback/:id` - Детайли на обратна връзка +- `PUT /api/feedback/:id` - Актуализирай обратна връзка +- `DELETE /api/feedback/:id` - Изтрий обратна връзка + +### Analytics +- `GET /api/analytics/dashboard` - Dashboard статистика +- `GET /api/analytics/contacts` - Анализ на контакти +- `GET /api/analytics/campaigns` - Анализ на кампании +- `GET /api/analytics/tickets` - Анализ на билети +- `GET /api/analytics/feedback` - Анализ на обратна връзка + +## Тестване + +### Регистрация +```bash +curl -X POST http://localhost:3001/api/users/register \ + -H "Content-Type: application/json" \ + -d '{ + "firstName": "John", + "lastName": "Doe", + "email": "john@example.com", + "username": "johndoe", + "password": "Test123!@#" + }' +``` + +### Логин +```bash +curl -X POST http://localhost:3001/api/users/login \ + -H "Content-Type: application/json" \ + -d '{ + "username": "johndoe", + "password": "Test123!@#" + }' +``` + +## Лицензия + +Proprietary - Всички права запазени + +## Контакт + +За въпроси: sabo@inex-project.net diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8f71793 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +version: '3.8' + +services: + # Backend API + backend: + image: node:18-alpine + container_name: digitcrm-backend + working_dir: /app + command: > + sh -c " + apk add --no-cache git && + cd backend && + npm install --production && + npm start + " + ports: + - "3001:3000" + environment: + - NODE_ENV=production + - PORT=3000 + - DB_SERVER=cw.inex-project.net + - DB_PORT=14333 + - DB_USER=inex + - DB_PASSWORD=inex123!@# + - JWT_SECRET=your-secret-key-change-in-production-12345 + - CORS_ORIGIN=http://localhost:3000,https://digitcrm.inex-project.net + restart: unless-stopped + networks: + - digitcrm-network + volumes: + - ./backend:/app/backend + + # Frontend SPA + frontend: + image: node:18-alpine + container_name: digitcrm-frontend + working_dir: /app + command: > + sh -c " + cd frontend && + npm install -g pnpm && + pnpm install --frozen-lockfile && + pnpm run build && + pnpm run preview + " + ports: + - "3000:4173" + environment: + - VITE_API_URL=http://localhost:3001/api + restart: unless-stopped + networks: + - digitcrm-network + depends_on: + - backend + volumes: + - ./frontend:/app/frontend + +networks: + digitcrm-network: + driver: bridge diff --git a/frontend/client/index.html b/frontend/client/index.html new file mode 100644 index 0000000..aeb6013 --- /dev/null +++ b/frontend/client/index.html @@ -0,0 +1,26 @@ + + + + + + + CRM System Frontend + + + + +
+ + + + + diff --git a/frontend/client/public/.gitkeep b/frontend/client/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/client/public/__manus__/debug-collector.js b/frontend/client/public/__manus__/debug-collector.js new file mode 100644 index 0000000..0504555 --- /dev/null +++ b/frontend/client/public/__manus__/debug-collector.js @@ -0,0 +1,821 @@ +/** + * Manus Debug Collector (agent-friendly) + * + * Captures: + * 1) Console logs + * 2) Network requests (fetch + XHR) + * 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.) + * + * Data is periodically sent to /__manus__/logs + * Note: uiEvents are mirrored to sessionEvents for sessionReplay.log + */ +(function () { + "use strict"; + + // Prevent double initialization + if (window.__MANUS_DEBUG_COLLECTOR__) return; + + // ========================================================================== + // Configuration + // ========================================================================== + const CONFIG = { + reportEndpoint: "/__manus__/logs", + bufferSize: { + console: 500, + network: 200, + // semantic, agent-friendly UI events + ui: 500, + }, + reportInterval: 2000, + sensitiveFields: [ + "password", + "token", + "secret", + "key", + "authorization", + "cookie", + "session", + ], + maxBodyLength: 10240, + // UI event logging privacy policy: + // - inputs matching sensitiveFields or type=password are masked by default + // - non-sensitive inputs log up to 200 chars + uiInputMaxLen: 200, + uiTextMaxLen: 80, + // Scroll throttling: minimum ms between scroll events + scrollThrottleMs: 500, + }; + + // ========================================================================== + // Storage + // ========================================================================== + const store = { + consoleLogs: [], + networkRequests: [], + uiEvents: [], + lastReportTime: Date.now(), + lastScrollTime: 0, + }; + + // ========================================================================== + // Utility Functions + // ========================================================================== + + function sanitizeValue(value, depth) { + if (depth === void 0) depth = 0; + if (depth > 5) return "[Max Depth]"; + if (value === null) return null; + if (value === undefined) return undefined; + + if (typeof value === "string") { + return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value; + } + + if (typeof value !== "object") return value; + + if (Array.isArray(value)) { + return value.slice(0, 100).map(function (v) { + return sanitizeValue(v, depth + 1); + }); + } + + var sanitized = {}; + for (var k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + var isSensitive = CONFIG.sensitiveFields.some(function (f) { + return k.toLowerCase().indexOf(f) !== -1; + }); + if (isSensitive) { + sanitized[k] = "[REDACTED]"; + } else { + sanitized[k] = sanitizeValue(value[k], depth + 1); + } + } + } + return sanitized; + } + + function formatArg(arg) { + try { + if (arg instanceof Error) { + return { type: "Error", message: arg.message, stack: arg.stack }; + } + if (typeof arg === "object") return sanitizeValue(arg); + return String(arg); + } catch (e) { + return "[Unserializable]"; + } + } + + function formatArgs(args) { + var result = []; + for (var i = 0; i < args.length; i++) result.push(formatArg(args[i])); + return result; + } + + function pruneBuffer(buffer, maxSize) { + if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize); + } + + function tryParseJson(str) { + if (typeof str !== "string") return str; + try { + return JSON.parse(str); + } catch (e) { + return str; + } + } + + // ========================================================================== + // Semantic UI Event Logging (agent-friendly) + // ========================================================================== + + function shouldIgnoreTarget(target) { + try { + if (!target || !(target instanceof Element)) return false; + return !!target.closest(".manus-no-record"); + } catch (e) { + return false; + } + } + + function compactText(s, maxLen) { + try { + var t = (s || "").trim().replace(/\s+/g, " "); + if (!t) return ""; + return t.length > maxLen ? t.slice(0, maxLen) + "…" : t; + } catch (e) { + return ""; + } + } + + function elText(el) { + try { + var t = el.innerText || el.textContent || ""; + return compactText(t, CONFIG.uiTextMaxLen); + } catch (e) { + return ""; + } + } + + function describeElement(el) { + if (!el || !(el instanceof Element)) return null; + + var getAttr = function (name) { + return el.getAttribute(name); + }; + + var tag = el.tagName ? el.tagName.toLowerCase() : null; + var id = el.id || null; + var name = getAttr("name") || null; + var role = getAttr("role") || null; + var ariaLabel = getAttr("aria-label") || null; + + var dataLoc = getAttr("data-loc") || null; + var testId = + getAttr("data-testid") || + getAttr("data-test-id") || + getAttr("data-test") || + null; + + var type = tag === "input" ? (getAttr("type") || "text") : null; + var href = tag === "a" ? getAttr("href") || null : null; + + // a small, stable hint for agents (avoid building full CSS paths) + var selectorHint = null; + if (testId) selectorHint = '[data-testid="' + testId + '"]'; + else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]'; + else if (id) selectorHint = "#" + id; + else selectorHint = tag || "unknown"; + + return { + tag: tag, + id: id, + name: name, + type: type, + role: role, + ariaLabel: ariaLabel, + testId: testId, + dataLoc: dataLoc, + href: href, + text: elText(el), + selectorHint: selectorHint, + }; + } + + function isSensitiveField(el) { + if (!el || !(el instanceof Element)) return false; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea") return false; + + var type = (el.getAttribute("type") || "").toLowerCase(); + if (type === "password") return true; + + var name = (el.getAttribute("name") || "").toLowerCase(); + var id = (el.id || "").toLowerCase(); + + return CONFIG.sensitiveFields.some(function (f) { + return name.indexOf(f) !== -1 || id.indexOf(f) !== -1; + }); + } + + function getInputValueSafe(el) { + if (!el || !(el instanceof Element)) return null; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea" && tag !== "select") return null; + + var v = ""; + try { + v = el.value != null ? String(el.value) : ""; + } catch (e) { + v = ""; + } + + if (isSensitiveField(el)) return { masked: true, length: v.length }; + + if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…"; + return v; + } + + function logUiEvent(kind, payload) { + var entry = { + timestamp: Date.now(), + kind: kind, + url: location.href, + viewport: { width: window.innerWidth, height: window.innerHeight }, + payload: sanitizeValue(payload), + }; + store.uiEvents.push(entry); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + } + + function installUiEventListeners() { + // Clicks + document.addEventListener( + "click", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("click", { + target: describeElement(t), + x: e.clientX, + y: e.clientY, + }); + }, + true + ); + + // Typing "commit" events + document.addEventListener( + "change", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("change", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + document.addEventListener( + "focusin", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusin", { target: describeElement(t) }); + }, + true + ); + + document.addEventListener( + "focusout", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusout", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + // Enter/Escape are useful for form flows & modals + document.addEventListener( + "keydown", + function (e) { + if (e.key !== "Enter" && e.key !== "Escape") return; + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("keydown", { key: e.key, target: describeElement(t) }); + }, + true + ); + + // Form submissions + document.addEventListener( + "submit", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("submit", { target: describeElement(t) }); + }, + true + ); + + // Throttled scroll events + window.addEventListener( + "scroll", + function () { + var now = Date.now(); + if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return; + store.lastScrollTime = now; + + logUiEvent("scroll", { + scrollX: window.scrollX, + scrollY: window.scrollY, + documentHeight: document.documentElement.scrollHeight, + viewportHeight: window.innerHeight, + }); + }, + { passive: true } + ); + + // Navigation tracking for SPAs + function nav(reason) { + logUiEvent("navigate", { reason: reason }); + } + + var origPush = history.pushState; + history.pushState = function () { + origPush.apply(this, arguments); + nav("pushState"); + }; + + var origReplace = history.replaceState; + history.replaceState = function () { + origReplace.apply(this, arguments); + nav("replaceState"); + }; + + window.addEventListener("popstate", function () { + nav("popstate"); + }); + window.addEventListener("hashchange", function () { + nav("hashchange"); + }); + } + + // ========================================================================== + // Console Interception + // ========================================================================== + + var originalConsole = { + log: console.log.bind(console), + debug: console.debug.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + ["log", "debug", "info", "warn", "error"].forEach(function (method) { + console[method] = function () { + var args = Array.prototype.slice.call(arguments); + + var entry = { + timestamp: Date.now(), + level: method.toUpperCase(), + args: formatArgs(args), + stack: method === "error" ? new Error().stack : null, + }; + + store.consoleLogs.push(entry); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + originalConsole[method].apply(console, args); + }; + }); + + window.addEventListener("error", function (event) { + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UncaughtError", + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error ? event.error.stack : null, + }, + ], + stack: event.error ? event.error.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + // Mark an error moment in UI event stream for agents + logUiEvent("error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); + }); + + window.addEventListener("unhandledrejection", function (event) { + var reason = event.reason; + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UnhandledRejection", + reason: reason && reason.message ? reason.message : String(reason), + stack: reason && reason.stack ? reason.stack : null, + }, + ], + stack: reason && reason.stack ? reason.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + logUiEvent("unhandledrejection", { + reason: reason && reason.message ? reason.message : String(reason), + }); + }); + + // ========================================================================== + // Fetch Interception + // ========================================================================== + + var originalFetch = window.fetch.bind(window); + + window.fetch = function (input, init) { + init = init || {}; + var startTime = Date.now(); + // Handle string, Request object, or URL object + var url = typeof input === "string" + ? input + : (input && (input.url || input.href || String(input))) || ""; + var method = init.method || (input && input.method) || "GET"; + + // Don't intercept internal requests + if (url.indexOf("/__manus__/") === 0) { + return originalFetch(input, init); + } + + // Safely parse headers (avoid breaking if headers format is invalid) + var requestHeaders = {}; + try { + if (init.headers) { + requestHeaders = Object.fromEntries(new Headers(init.headers).entries()); + } + } catch (e) { + requestHeaders = { _parseError: true }; + } + + var entry = { + timestamp: startTime, + type: "fetch", + method: method.toUpperCase(), + url: url, + request: { + headers: requestHeaders, + body: init.body ? sanitizeValue(tryParseJson(init.body)) : null, + }, + response: null, + duration: null, + error: null, + }; + + return originalFetch(input, init) + .then(function (response) { + entry.duration = Date.now() - startTime; + + var contentType = (response.headers.get("content-type") || "").toLowerCase(); + var contentLength = response.headers.get("content-length"); + + entry.response = { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: null, + }; + + // Semantic network hint for agents on failures (sync, no need to wait for body) + if (response.status >= 400) { + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + status: response.status, + statusText: response.statusText, + }); + } + + // Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + if (isStreaming) { + entry.response.body = "[Streaming response - not captured]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for large responses to avoid memory issues + if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) { + entry.response.body = "[Response too large: " + contentLength + " bytes]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + if (isBinary) { + entry.response.body = "[Binary content: " + contentType + "]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // For text responses, clone and read body in background + var clonedResponse = response.clone(); + + // Async: read body in background, don't block the response + clonedResponse + .text() + .then(function (text) { + if (text.length <= CONFIG.maxBodyLength) { + entry.response.body = sanitizeValue(tryParseJson(text)); + } else { + entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } + }) + .catch(function () { + entry.response.body = "[Unable to read body]"; + }) + .finally(function () { + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + }); + + // Return response immediately, don't wait for body reading + return response; + }) + .catch(function (error) { + entry.duration = Date.now() - startTime; + entry.error = { message: error.message, stack: error.stack }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + message: error.message, + }); + + throw error; + }); + }; + + // ========================================================================== + // XHR Interception + // ========================================================================== + + var originalXHROpen = XMLHttpRequest.prototype.open; + var originalXHRSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url) { + this._manusData = { + method: (method || "GET").toUpperCase(), + url: url, + startTime: null, + }; + return originalXHROpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function (body) { + var xhr = this; + + if ( + xhr._manusData && + xhr._manusData.url && + xhr._manusData.url.indexOf("/__manus__/") !== 0 + ) { + xhr._manusData.startTime = Date.now(); + xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null; + + xhr.addEventListener("load", function () { + var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase(); + var responseBody = null; + + // Skip body capture for streaming responses + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + + if (isStreaming) { + responseBody = "[Streaming response - not captured]"; + } else if (isBinary) { + responseBody = "[Binary content: " + contentType + "]"; + } else { + // Safe to read responseText for text responses + try { + var text = xhr.responseText || ""; + if (text.length > CONFIG.maxBodyLength) { + responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } else { + responseBody = sanitizeValue(tryParseJson(text)); + } + } catch (e) { + // responseText may throw for non-text responses + responseBody = "[Unable to read response: " + e.message + "]"; + } + } + + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: { + status: xhr.status, + statusText: xhr.statusText, + body: responseBody, + }, + duration: Date.now() - xhr._manusData.startTime, + error: null, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + if (entry.response && entry.response.status >= 400) { + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + status: entry.response.status, + statusText: entry.response.statusText, + }); + } + }); + + xhr.addEventListener("error", function () { + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: null, + duration: Date.now() - xhr._manusData.startTime, + error: { message: "Network error" }, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + message: "Network error", + }); + }); + } + + return originalXHRSend.apply(this, arguments); + }; + + // ========================================================================== + // Data Reporting + // ========================================================================== + + function reportLogs() { + var consoleLogs = store.consoleLogs.splice(0); + var networkRequests = store.networkRequests.splice(0); + var uiEvents = store.uiEvents.splice(0); + + // Skip if no new data + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return Promise.resolve(); + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + // agent-friendly semantic events + uiEvents: uiEvents, + }; + + return originalFetch(CONFIG.reportEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch(function () { + // Put data back on failure (but respect limits) + store.consoleLogs = consoleLogs.concat(store.consoleLogs); + store.networkRequests = networkRequests.concat(store.networkRequests); + store.uiEvents = uiEvents.concat(store.uiEvents); + + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + }); + } + + // Periodic reporting + setInterval(reportLogs, CONFIG.reportInterval); + + // Report on page unload + window.addEventListener("beforeunload", function () { + var consoleLogs = store.consoleLogs; + var networkRequests = store.networkRequests; + var uiEvents = store.uiEvents; + + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return; + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + uiEvents: uiEvents, + }; + + if (navigator.sendBeacon) { + var payloadStr = JSON.stringify(payload); + // sendBeacon has ~64KB limit, truncate if too large + var MAX_BEACON_SIZE = 60000; // Leave some margin + if (payloadStr.length > MAX_BEACON_SIZE) { + // Prioritize: keep recent events, drop older logs + var truncatedPayload = { + timestamp: Date.now(), + consoleLogs: consoleLogs.slice(-50), + networkRequests: networkRequests.slice(-20), + sessionEvents: uiEvents.slice(-100), + uiEvents: uiEvents.slice(-100), + _truncated: true, + }; + payloadStr = JSON.stringify(truncatedPayload); + } + navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr); + } + }); + + // ========================================================================== + // Initialization + // ========================================================================== + + // Install semantic UI listeners ASAP + try { + installUiEventListeners(); + } catch (e) { + console.warn("[Manus] Failed to install UI listeners:", e); + } + + // Mark as initialized + window.__MANUS_DEBUG_COLLECTOR__ = { + version: "2.0-no-rrweb", + store: store, + forceReport: reportLogs, + }; + + console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)"); +})(); diff --git a/frontend/client/public/__manus__/version.json b/frontend/client/public/__manus__/version.json new file mode 100644 index 0000000..a092e0c --- /dev/null +++ b/frontend/client/public/__manus__/version.json @@ -0,0 +1,4 @@ +{ + "timestamp": 1782475773091, + "version": "ae8f5d25" +} \ No newline at end of file diff --git a/frontend/client/src/App.tsx b/frontend/client/src/App.tsx new file mode 100644 index 0000000..59c6f6e --- /dev/null +++ b/frontend/client/src/App.tsx @@ -0,0 +1,58 @@ +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import NotFound from "@/pages/NotFound"; +import { Route, Switch } from "wouter"; +import ErrorBoundary from "./components/ErrorBoundary"; +import { ThemeProvider } from "./contexts/ThemeContext"; +import Login from "./pages/Login"; +import Dashboard from "./pages/Dashboard"; +import Contacts from "./pages/Contacts"; +import Campaigns from "./pages/Campaigns"; +import Tickets from "./pages/Tickets"; +import Warranties from "./pages/Warranties"; +import Feedback from "./pages/Feedback"; +import Analytics from "./pages/Analytics"; + +function ProtectedRoute({ component: Component }: { component: React.ComponentType }) { + const token = localStorage.getItem('token'); + if (!token) { + window.location.href = '/login'; + return null; + } + return ; +} + +function Router() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Final fallback route */} + + + ); +} + +function App() { + return ( + + + + + + + + + ); +} + +export default App; diff --git a/frontend/client/src/components/DashboardLayout.tsx b/frontend/client/src/components/DashboardLayout.tsx new file mode 100644 index 0000000..4a902c8 --- /dev/null +++ b/frontend/client/src/components/DashboardLayout.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; +import { Link, useLocation } from 'wouter'; +import { + Users, + Mail, + Ticket, + Shield, + MessageSquare, + BarChart3, + LogOut, + Menu, + X, + Settings, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface DashboardLayoutProps { + children: React.ReactNode; +} + +export default function DashboardLayout({ children }: DashboardLayoutProps) { + const [location] = useLocation(); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + const user = JSON.parse(localStorage.getItem('user') || '{}'); + + const menuItems = [ + { label: 'Контакти', href: '/contacts', icon: Users }, + { label: 'Кампании', href: '/campaigns', icon: Mail }, + { label: 'Билети', href: '/tickets', icon: Ticket }, + { label: 'Гаранции', href: '/warranties', icon: Shield }, + { label: 'Обратна връзка', href: '/feedback', icon: MessageSquare }, + { label: 'Аналитика', href: '/analytics', icon: BarChart3 }, + ]; + + const handleLogout = () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/login'; + }; + + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {/* Top Bar */} +
+ +
+ {menuItems.find((item) => item.href === location)?.label || 'CRM'} +
+
+
+ + {/* Page Content */} +
+
{children}
+
+
+ + {/* Mobile Menu Overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} +
+ ); +} diff --git a/frontend/client/src/components/ErrorBoundary.tsx b/frontend/client/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..1422986 --- /dev/null +++ b/frontend/client/src/components/ErrorBoundary.tsx @@ -0,0 +1,62 @@ +import { cn } from "@/lib/utils"; +import { AlertTriangle, RotateCcw } from "lucide-react"; +import { Component, ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( +
+
+ + +

An unexpected error occurred.

+ +
+
+                {this.state.error?.stack}
+              
+
+ + +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/client/src/components/ManusDialog.tsx b/frontend/client/src/components/ManusDialog.tsx new file mode 100644 index 0000000..0aeff4b --- /dev/null +++ b/frontend/client/src/components/ManusDialog.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; + +interface ManusDialogProps { + title?: string; + logo?: string; + open?: boolean; + onLogin: () => void; + onOpenChange?: (open: boolean) => void; + onClose?: () => void; +} + +export function ManusDialog({ + title, + logo, + open = false, + onLogin, + onOpenChange, + onClose, +}: ManusDialogProps) { + const [internalOpen, setInternalOpen] = useState(open); + + useEffect(() => { + if (!onOpenChange) { + setInternalOpen(open); + } + }, [open, onOpenChange]); + + const handleOpenChange = (nextOpen: boolean) => { + if (onOpenChange) { + onOpenChange(nextOpen); + } else { + setInternalOpen(nextOpen); + } + + if (!nextOpen) { + onClose?.(); + } + }; + + return ( + + +
+ {logo ? ( +
+ Dialog graphic +
+ ) : null} + + {/* Title and subtitle */} + {title ? ( + + {title} + + ) : null} + + Please login with Manus to continue + +
+ + + {/* Login button */} + + +
+
+ ); +} diff --git a/frontend/client/src/components/Map.tsx b/frontend/client/src/components/Map.tsx new file mode 100644 index 0000000..4849e05 --- /dev/null +++ b/frontend/client/src/components/Map.tsx @@ -0,0 +1,155 @@ +/** + * GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE + * + * USAGE FROM PARENT COMPONENT: + * ====== + * + * const mapRef = useRef(null); + * + * { + * mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state. + * + * + * ====== + * Available Libraries and Core Features: + * ------------------------------- + * 📍 MARKER (from `marker` library) + * - Attaches to map using { map, position } + * new google.maps.marker.AdvancedMarkerElement({ + * map, + * position: { lat: 37.7749, lng: -122.4194 }, + * title: "San Francisco", + * }); + * + * ------------------------------- + * 🏢 PLACES (from `places` library) + * - Does not attach directly to map; use data with your map manually. + * const place = new google.maps.places.Place({ id: PLACE_ID }); + * await place.fetchFields({ fields: ["displayName", "location"] }); + * map.setCenter(place.location); + * new google.maps.marker.AdvancedMarkerElement({ map, position: place.location }); + * + * ------------------------------- + * 🧭 GEOCODER (from `geocoding` library) + * - Standalone service; manually apply results to map. + * const geocoder = new google.maps.Geocoder(); + * geocoder.geocode({ address: "New York" }, (results, status) => { + * if (status === "OK" && results[0]) { + * map.setCenter(results[0].geometry.location); + * new google.maps.marker.AdvancedMarkerElement({ + * map, + * position: results[0].geometry.location, + * }); + * } + * }); + * + * ------------------------------- + * 📐 GEOMETRY (from `geometry` library) + * - Pure utility functions; not attached to map. + * const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2); + * + * ------------------------------- + * 🛣️ ROUTES (from `routes` library) + * - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached) + * const directionsService = new google.maps.DirectionsService(); + * const directionsRenderer = new google.maps.DirectionsRenderer({ map }); + * directionsService.route( + * { origin, destination, travelMode: "DRIVING" }, + * (res, status) => status === "OK" && directionsRenderer.setDirections(res) + * ); + * + * ------------------------------- + * 🌦️ MAP LAYERS (attach directly to map) + * - new google.maps.TrafficLayer().setMap(map); + * - new google.maps.TransitLayer().setMap(map); + * - new google.maps.BicyclingLayer().setMap(map); + * + * ------------------------------- + * ✅ SUMMARY + * - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers. + * - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService. + * - “data-only” → Place, Geometry utilities. + */ + +/// + +import { useEffect, useRef } from "react"; +import { usePersistFn } from "@/hooks/usePersistFn"; +import { cn } from "@/lib/utils"; + +declare global { + interface Window { + google?: typeof google; + } +} + +const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY; +const FORGE_BASE_URL = + import.meta.env.VITE_FRONTEND_FORGE_API_URL || + "https://forge.butterfly-effect.dev"; +const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`; + +function loadMapScript() { + return new Promise(resolve => { + const script = document.createElement("script"); + script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`; + script.async = true; + script.crossOrigin = "anonymous"; + script.onload = () => { + resolve(null); + script.remove(); // Clean up immediately + }; + script.onerror = () => { + console.error("Failed to load Google Maps script"); + }; + document.head.appendChild(script); + }); +} + +interface MapViewProps { + className?: string; + initialCenter?: google.maps.LatLngLiteral; + initialZoom?: number; + onMapReady?: (map: google.maps.Map) => void; +} + +export function MapView({ + className, + initialCenter = { lat: 37.7749, lng: -122.4194 }, + initialZoom = 12, + onMapReady, +}: MapViewProps) { + const mapContainer = useRef(null); + const map = useRef(null); + + const init = usePersistFn(async () => { + await loadMapScript(); + if (!mapContainer.current) { + console.error("Map container not found"); + return; + } + map.current = new window.google.maps.Map(mapContainer.current, { + zoom: initialZoom, + center: initialCenter, + mapTypeControl: true, + fullscreenControl: true, + zoomControl: true, + streetViewControl: true, + mapId: "DEMO_MAP_ID", + }); + if (onMapReady) { + onMapReady(map.current); + } + }); + + useEffect(() => { + init(); + }, [init]); + + return ( +
+ ); +} diff --git a/frontend/client/src/components/ui/accordion.tsx b/frontend/client/src/components/ui/accordion.tsx new file mode 100644 index 0000000..62705e3 --- /dev/null +++ b/frontend/client/src/components/ui/accordion.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Accordion({ + ...props +}: React.ComponentProps) { + return ; +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/frontend/client/src/components/ui/alert-dialog.tsx b/frontend/client/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..6949979 --- /dev/null +++ b/frontend/client/src/components/ui/alert-dialog.tsx @@ -0,0 +1,155 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/frontend/client/src/components/ui/alert.tsx b/frontend/client/src/components/ui/alert.tsx new file mode 100644 index 0000000..5b1a0b5 --- /dev/null +++ b/frontend/client/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/frontend/client/src/components/ui/aspect-ratio.tsx b/frontend/client/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..01d045d --- /dev/null +++ b/frontend/client/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,9 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return ; +} + +export { AspectRatio }; diff --git a/frontend/client/src/components/ui/avatar.tsx b/frontend/client/src/components/ui/avatar.tsx new file mode 100644 index 0000000..02305fd --- /dev/null +++ b/frontend/client/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/frontend/client/src/components/ui/badge.tsx b/frontend/client/src/components/ui/badge.tsx new file mode 100644 index 0000000..83750ed --- /dev/null +++ b/frontend/client/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/frontend/client/src/components/ui/breadcrumb.tsx b/frontend/client/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..9d88a37 --- /dev/null +++ b/frontend/client/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return