mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
Compare commits
7 Commits
d2b04386d1
...
dev-kartik
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0570a274ad | ||
|
|
ea5d8ed89a | ||
|
|
698bdf488a | ||
|
|
dc59189cc6 | ||
|
|
c3fb1f0cf3 | ||
|
|
95d4009214 | ||
|
|
2c87a39733 |
38
.claudeignore
Normal file
38
.claudeignore
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Lock files (large, rarely useful)
|
||||||
|
package-lock.json
|
||||||
|
bun.lock
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Generated / cache
|
||||||
|
nanobanana-output/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Design / static assets
|
||||||
|
public/
|
||||||
|
src/components/shared-assets/
|
||||||
|
|
||||||
|
# Type declaration outputs
|
||||||
|
**/*.d.ts
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# GitHub workflows (not relevant to code tasks)
|
||||||
|
.github/
|
||||||
|
|
||||||
|
# Scripts (deployment/utility scripts rarely needed)
|
||||||
|
scripts/
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -5,7 +5,10 @@
|
|||||||
"@trivago/prettier-plugin-sort-imports",
|
"@trivago/prettier-plugin-sort-imports",
|
||||||
"prettier-plugin-tailwindcss"
|
"prettier-plugin-tailwindcss"
|
||||||
],
|
],
|
||||||
"tailwindFunctions": ["sortCx", "cx"],
|
"tailwindFunctions": [
|
||||||
|
"sortCx",
|
||||||
|
"cx"
|
||||||
|
],
|
||||||
"importOrder": [
|
"importOrder": [
|
||||||
"^react$",
|
"^react$",
|
||||||
"^react-dom$",
|
"^react-dom$",
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -40,7 +40,7 @@ npm run build # TypeScript check + production build
|
|||||||
### Environment Variables (set at build time or in `.env`)
|
### Environment Variables (set at build time or in `.env`)
|
||||||
|
|
||||||
| Variable | Purpose | Dev Default | Production |
|
| Variable | Purpose | Dev Default | Production |
|
||||||
|----------|---------|-------------|------------|
|
| -------------------- | ---------------- | ----------------------- | ------------------------------------------- |
|
||||||
| `VITE_API_URL` | Platform GraphQL | `http://localhost:4000` | `https://engage-api.srv1477139.hstgr.cloud` |
|
| `VITE_API_URL` | Platform GraphQL | `http://localhost:4000` | `https://engage-api.srv1477139.hstgr.cloud` |
|
||||||
| `VITE_SIDECAR_URL` | Sidecar REST API | `http://localhost:4100` | `https://engage-api.srv1477139.hstgr.cloud` |
|
| `VITE_SIDECAR_URL` | Sidecar REST API | `http://localhost:4100` | `https://engage-api.srv1477139.hstgr.cloud` |
|
||||||
| `VITE_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
|
| `VITE_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
|
||||||
@@ -48,6 +48,7 @@ npm run build # TypeScript check + production build
|
|||||||
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
|
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
|
||||||
|
|
||||||
**Production build command:**
|
**Production build command:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
|
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
|
||||||
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud \
|
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud \
|
||||||
@@ -123,34 +124,42 @@ src/
|
|||||||
## Troubleshooting Guide — Where to Look
|
## Troubleshooting Guide — Where to Look
|
||||||
|
|
||||||
### "The call desk isn't working"
|
### "The call desk isn't working"
|
||||||
|
|
||||||
**File:** `src/pages/call-desk.tsx`
|
**File:** `src/pages/call-desk.tsx`
|
||||||
This is the orchestrator. It uses `useSip()` for call state, `useWorklist()` for the task queue, and renders either `ActiveCallCard` (in-call) or `WorklistPanel` (idle). Start here, then drill into whichever child component is misbehaving.
|
This is the orchestrator. It uses `useSip()` for call state, `useWorklist()` for the task queue, and renders either `ActiveCallCard` (in-call) or `WorklistPanel` (idle). Start here, then drill into whichever child component is misbehaving.
|
||||||
|
|
||||||
### "Calls aren't connecting / SIP errors"
|
### "Calls aren't connecting / SIP errors"
|
||||||
|
|
||||||
**File:** `src/providers/sip-provider.tsx` + `src/state/sip-state.ts`
|
**File:** `src/providers/sip-provider.tsx` + `src/state/sip-state.ts`
|
||||||
Check `VITE_SIP_*` env vars. Ozonetel SIP WebSocket runs on **port 444** — VPNs block it. If WebSocket hangs at "connecting", turn off VPN. Also check browser console for SIP.js registration errors.
|
Check `VITE_SIP_*` env vars. Ozonetel SIP WebSocket runs on **port 444** — VPNs block it. If WebSocket hangs at "connecting", turn off VPN. Also check browser console for SIP.js registration errors.
|
||||||
|
|
||||||
### "Worklist not loading / empty"
|
### "Worklist not loading / empty"
|
||||||
|
|
||||||
**File:** `src/hooks/use-worklist.ts`
|
**File:** `src/hooks/use-worklist.ts`
|
||||||
This polls `GET /api/worklist` on the sidecar every 30s. Open browser Network tab → filter for `/api/worklist`. Common causes: sidecar is down, auth token expired, or agent name doesn't match any assigned leads.
|
This polls `GET /api/worklist` on the sidecar every 30s. Open browser Network tab → filter for `/api/worklist`. Common causes: sidecar is down, auth token expired, or agent name doesn't match any assigned leads.
|
||||||
|
|
||||||
### "Missed calls not appearing / sub-tabs empty"
|
### "Missed calls not appearing / sub-tabs empty"
|
||||||
|
|
||||||
**File:** `src/components/call-desk/worklist-panel.tsx`
|
**File:** `src/components/call-desk/worklist-panel.tsx`
|
||||||
Missed calls come from the sidecar worklist response. The sub-tabs filter by `callbackstatus` field. If all sub-tabs are empty, the sidecar ingestion may not be running (check sidecar logs for `MissedQueueService`).
|
Missed calls come from the sidecar worklist response. The sub-tabs filter by `callbackstatus` field. If all sub-tabs are empty, the sidecar ingestion may not be running (check sidecar logs for `MissedQueueService`).
|
||||||
|
|
||||||
### "Disposition / appointment not saving"
|
### "Disposition / appointment not saving"
|
||||||
|
|
||||||
**File:** `src/components/call-desk/active-call-card.tsx` → `handleDisposition()`
|
**File:** `src/components/call-desk/active-call-card.tsx` → `handleDisposition()`
|
||||||
Posts to sidecar `POST /api/ozonetel/dispose`. Errors are caught silently (non-blocking). Check browser Network tab for the dispose request/response, then check sidecar logs.
|
Posts to sidecar `POST /api/ozonetel/dispose`. Errors are caught silently (non-blocking). Check browser Network tab for the dispose request/response, then check sidecar logs.
|
||||||
|
|
||||||
### "Login broken / Failed to fetch"
|
### "Login broken / Failed to fetch"
|
||||||
|
|
||||||
**File:** `src/pages/login.tsx` + `src/lib/api-client.ts`
|
**File:** `src/pages/login.tsx` + `src/lib/api-client.ts`
|
||||||
Login calls `apiClient.login()` → sidecar `/auth/login` → platform GraphQL. Most common cause: wrong `VITE_API_URL` (built with localhost instead of production URL). **Always set env vars at build time.**
|
Login calls `apiClient.login()` → sidecar `/auth/login` → platform GraphQL. Most common cause: wrong `VITE_API_URL` (built with localhost instead of production URL). **Always set env vars at build time.**
|
||||||
|
|
||||||
### "UI component looks wrong"
|
### "UI component looks wrong"
|
||||||
|
|
||||||
**Files:** `src/components/base/` (primitives), `src/components/application/` (complex)
|
**Files:** `src/components/base/` (primitives), `src/components/application/` (complex)
|
||||||
These come from the Untitled UI library. Design tokens are in `src/styles/theme.css`. Brand colors were rebuilt from logo blue `rgb(32, 96, 160)`.
|
These come from the Untitled UI library. Design tokens are in `src/styles/theme.css`. Brand colors were rebuilt from logo blue `rgb(32, 96, 160)`.
|
||||||
|
|
||||||
### "Navigation / role-based access"
|
### "Navigation / role-based access"
|
||||||
|
|
||||||
**File:** `src/components/layout/sidebar.tsx`
|
**File:** `src/components/layout/sidebar.tsx`
|
||||||
Navigation groups are defined per role (admin, cc-agent, executive). Routes are registered in `src/main.tsx`.
|
Navigation groups are defined per role (admin, cc-agent, executive). Routes are registered in `src/main.tsx`.
|
||||||
|
|
||||||
@@ -173,6 +182,7 @@ Component (e.g. ActiveCallCard)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key pattern:** The frontend talks to TWO backends:
|
**Key pattern:** The frontend talks to TWO backends:
|
||||||
|
|
||||||
1. **Sidecar** (REST) — for Ozonetel telephony operations and worklist
|
1. **Sidecar** (REST) — for Ozonetel telephony operations and worklist
|
||||||
2. **Platform** (GraphQL) — for entity CRUD (leads, appointments, patients)
|
2. **Platform** (GraphQL) — for entity CRUD (leads, appointments, patients)
|
||||||
|
|
||||||
|
|||||||
41
eslint.config.mjs
Normal file
41
eslint.config.mjs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// @ts-check
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist', 'node_modules'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// React Hooks — enforce rules of hooks and exhaustive deps
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
|
||||||
|
// React Refresh — warn on non-component exports (Vite HMR)
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
|
||||||
|
// TypeScript
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||||
|
|
||||||
|
// General
|
||||||
|
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||||
|
'no-duplicate-imports': 'error',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
4641
pnpm-lock.yaml
generated
Normal file
4641
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
227
scripts/fix-duplicate-imports.mjs
Normal file
227
scripts/fix-duplicate-imports.mjs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* fix-duplicate-imports.mjs
|
||||||
|
*
|
||||||
|
* Merges duplicate import statements from the same module across all TypeScript
|
||||||
|
* source files in the project. Run this whenever `npm run lint` reports
|
||||||
|
* `no-duplicate-imports` errors.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/fix-duplicate-imports.mjs
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* import type { A, B } from 'module' — type-only imports
|
||||||
|
* import Default from 'module' — default imports
|
||||||
|
* import Default, { A, B } from 'mod' — mixed default + named
|
||||||
|
* import { A, B } from 'module' — named imports
|
||||||
|
* import {\n A,\n B\n} from 'mod' — multi-line named imports
|
||||||
|
*
|
||||||
|
* When merging a `import type` with a value import from the same module,
|
||||||
|
* type-only specifiers are inlined as `type Name` in the merged statement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
||||||
|
import { extname, join, resolve } from "path";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const projectRoot = resolve(new URL(".", import.meta.url).pathname, "..");
|
||||||
|
const srcDir = join(projectRoot, "src");
|
||||||
|
const extensions = new Set([".ts", ".tsx"]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File discovery — recursively find all TS/TSX files under src/
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function findFiles(dir) {
|
||||||
|
const results = [];
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const full = join(dir, entry);
|
||||||
|
if (statSync(full).isDirectory()) {
|
||||||
|
results.push(...findFiles(full));
|
||||||
|
} else if (extensions.has(extname(entry))) {
|
||||||
|
results.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import extraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all top-level import statements from file content.
|
||||||
|
* Returns array of { start, end, raw, module, isType, defaultImport, namedImports }
|
||||||
|
* where start/end are character positions in the content.
|
||||||
|
*/
|
||||||
|
function extractImports(content) {
|
||||||
|
const results = [];
|
||||||
|
const importRe = /^import\s+([\s\S]*?)from\s+['"]([^'"]+)['"]\s*;?/gm;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = importRe.exec(content)) !== null) {
|
||||||
|
const raw = match[0];
|
||||||
|
const specifierPart = match[1];
|
||||||
|
const moduleName = match[2];
|
||||||
|
|
||||||
|
const isType = /^type\s+/.test(specifierPart.trimStart());
|
||||||
|
const cleanSpec = specifierPart.replace(/^type\s+/, "").trim();
|
||||||
|
|
||||||
|
let defaultImport = null;
|
||||||
|
let namedStr = null;
|
||||||
|
|
||||||
|
const namespaceMatch = cleanSpec.match(/^\*\s+as\s+(\w+)/);
|
||||||
|
if (namespaceMatch) {
|
||||||
|
defaultImport = `* as ${namespaceMatch[1]}`;
|
||||||
|
} else {
|
||||||
|
const braceIdx = cleanSpec.indexOf("{");
|
||||||
|
if (braceIdx === -1) {
|
||||||
|
const def = cleanSpec.replace(/,$/, "").trim();
|
||||||
|
if (def) defaultImport = def;
|
||||||
|
} else {
|
||||||
|
const beforeBrace = cleanSpec.slice(0, braceIdx).replace(/,$/, "").trim();
|
||||||
|
if (beforeBrace) defaultImport = beforeBrace;
|
||||||
|
const closeBrace = cleanSpec.lastIndexOf("}");
|
||||||
|
namedStr = cleanSpec.slice(braceIdx + 1, closeBrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const namedImports = namedStr
|
||||||
|
? namedStr.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + raw.length,
|
||||||
|
raw,
|
||||||
|
module: moduleName,
|
||||||
|
isType,
|
||||||
|
defaultImport,
|
||||||
|
namedImports,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import merging
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a single merged import statement from multiple imports of the same module.
|
||||||
|
*
|
||||||
|
* - All type imports → merged `import type { ... }`
|
||||||
|
* - Mixed type + value → merged value import with inline `type Name` specifiers
|
||||||
|
*/
|
||||||
|
function buildMergedImport(moduleName, importList) {
|
||||||
|
const allType = importList.every((i) => i.isType);
|
||||||
|
|
||||||
|
if (allType) {
|
||||||
|
const allNamed = new Set(importList.flatMap((i) => i.namedImports));
|
||||||
|
const defaultImport = importList.map((i) => i.defaultImport).find(Boolean) ?? null;
|
||||||
|
const parts = [];
|
||||||
|
if (defaultImport) parts.push(defaultImport);
|
||||||
|
if (allNamed.size > 0) parts.push(`{ ${[...allNamed].join(", ")} }`);
|
||||||
|
if (parts.length === 0) return `import type "${moduleName}";`;
|
||||||
|
return `import type ${parts.join(", ")} from "${moduleName}";`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed: collect value default, type-only named, value named separately
|
||||||
|
let valueDefault = null;
|
||||||
|
const typeNamed = new Set();
|
||||||
|
const valueNamed = new Set();
|
||||||
|
|
||||||
|
for (const imp of importList) {
|
||||||
|
if (imp.defaultImport && !imp.isType) valueDefault = imp.defaultImport;
|
||||||
|
for (const n of imp.namedImports) {
|
||||||
|
(imp.isType ? typeNamed : valueNamed).add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build named specifiers: `type X` for type-only, plain for value
|
||||||
|
const typeSpecifiers = [...typeNamed].filter((n) => !valueNamed.has(n)).map((n) => `type ${n}`);
|
||||||
|
const valueSpecifiers = [...valueNamed];
|
||||||
|
const namedParts = [...typeSpecifiers, ...valueSpecifiers];
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (valueDefault) parts.push(valueDefault);
|
||||||
|
if (namedParts.length > 0) parts.push(`{ ${namedParts.join(", ")} }`);
|
||||||
|
if (parts.length === 0) return `import "${moduleName}";`;
|
||||||
|
return `import ${parts.join(", ")} from "${moduleName}";`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File fixer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function fixFile(filePath) {
|
||||||
|
let content = readFileSync(filePath, "utf-8");
|
||||||
|
const imports = extractImports(content);
|
||||||
|
if (imports.length === 0) return null;
|
||||||
|
|
||||||
|
// Group by module
|
||||||
|
const byModule = new Map();
|
||||||
|
for (const imp of imports) {
|
||||||
|
if (!byModule.has(imp.module)) byModule.set(imp.module, []);
|
||||||
|
byModule.get(imp.module).push(imp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (![...byModule.values()].some((v) => v.length > 1)) return null;
|
||||||
|
|
||||||
|
// Build merged text for each module
|
||||||
|
const mergedMap = new Map();
|
||||||
|
for (const [mod, imps] of byModule) {
|
||||||
|
mergedMap.set(mod, imps.length === 1 ? imps[0].raw : buildMergedImport(mod, imps));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build replacement list (process in reverse order to preserve character positions)
|
||||||
|
imports.sort((a, b) => a.start - b.start);
|
||||||
|
const placedModules = new Set();
|
||||||
|
const replacements = [];
|
||||||
|
|
||||||
|
for (const imp of imports) {
|
||||||
|
if (!placedModules.has(imp.module)) {
|
||||||
|
replacements.push({ start: imp.start, end: imp.end, replacement: mergedMap.get(imp.module) });
|
||||||
|
placedModules.add(imp.module);
|
||||||
|
} else {
|
||||||
|
// Remove duplicate, including its trailing newline
|
||||||
|
const end = content[imp.end] === "\n" ? imp.end + 1 : imp.end;
|
||||||
|
replacements.push({ start: imp.start, end, replacement: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replacements.sort((a, b) => b.start - a.start);
|
||||||
|
for (const { start, end, replacement } of replacements) {
|
||||||
|
content = content.slice(0, start) + replacement + content.slice(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const files = findFiles(srcDir);
|
||||||
|
let fixedCount = 0;
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
try {
|
||||||
|
const original = readFileSync(filePath, "utf-8");
|
||||||
|
const fixed = fixFile(filePath);
|
||||||
|
if (fixed && fixed !== original) {
|
||||||
|
writeFileSync(filePath, fixed, "utf-8");
|
||||||
|
const rel = filePath.replace(projectRoot + "/", "");
|
||||||
|
console.log(`Fixed: ${rel}`);
|
||||||
|
fixedCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const rel = filePath.replace(projectRoot + "/", "");
|
||||||
|
console.error(`Error: ${rel} — ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. Fixed ${fixedCount} file${fixedCount !== 1 ? "s" : ""}.`);
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { Link } from 'react-router';
|
import { Link } from "react-router";
|
||||||
|
import { formatCurrency } from "@/lib/format";
|
||||||
import { formatCurrency } from '@/lib/format';
|
import type { Campaign } from "@/types/entities";
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from "@/utils/cx";
|
||||||
import type { Campaign } from '@/types/entities';
|
|
||||||
|
|
||||||
interface CampaignRoiCardsProps {
|
interface CampaignRoiCardsProps {
|
||||||
campaigns: Campaign[];
|
campaigns: Campaign[];
|
||||||
@@ -34,9 +33,9 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
|
|||||||
}, [campaigns]);
|
}, [campaigns]);
|
||||||
|
|
||||||
const getHealthColor = (rate: number): string => {
|
const getHealthColor = (rate: number): string => {
|
||||||
if (rate >= 0.1) return 'bg-success-500';
|
if (rate >= 0.1) return "bg-success-500";
|
||||||
if (rate >= 0.05) return 'bg-warning-500';
|
if (rate >= 0.05) return "bg-warning-500";
|
||||||
return 'bg-error-500';
|
return "bg-error-500";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,17 +43,10 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
|
|||||||
<h3 className="text-sm font-bold text-primary">Campaign ROI</h3>
|
<h3 className="text-sm font-bold text-primary">Campaign ROI</h3>
|
||||||
<div className="flex gap-4 overflow-x-auto pb-1">
|
<div className="flex gap-4 overflow-x-auto pb-1">
|
||||||
{sorted.map((campaign) => (
|
{sorted.map((campaign) => (
|
||||||
<div
|
<div key={campaign.id} className="min-w-[220px] flex-shrink-0 rounded-xl border border-secondary bg-primary p-4">
|
||||||
key={campaign.id}
|
|
||||||
className="min-w-[220px] flex-shrink-0 rounded-xl border border-secondary bg-primary p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span className={cx("size-2 shrink-0 rounded-full", getHealthColor(campaign.conversionRate))} />
|
||||||
className={cx('size-2 shrink-0 rounded-full', getHealthColor(campaign.conversionRate))}
|
<span className="truncate text-sm font-semibold text-primary">{campaign.campaignName}</span>
|
||||||
/>
|
|
||||||
<span className="truncate text-sm font-semibold text-primary">
|
|
||||||
{campaign.campaignName}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center gap-3 text-xs text-tertiary">
|
<div className="mt-3 flex items-center gap-3 text-xs text-tertiary">
|
||||||
@@ -64,23 +56,14 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<span className="text-sm font-bold text-primary">
|
<span className="text-sm font-bold text-primary">{campaign.cac === Infinity ? "—" : formatCurrency(campaign.cac)}</span>
|
||||||
{campaign.cac === Infinity
|
|
||||||
? '—'
|
|
||||||
: formatCurrency(campaign.cac)}
|
|
||||||
</span>
|
|
||||||
<span className="ml-1 text-xs text-tertiary">CAC</span>
|
<span className="ml-1 text-xs text-tertiary">CAC</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 h-1 w-full overflow-hidden rounded-full bg-tertiary">
|
<div className="mt-3 h-1 w-full overflow-hidden rounded-full bg-tertiary">
|
||||||
<div
|
<div className="h-full rounded-full bg-brand-solid" style={{ width: `${Math.min(campaign.budgetProgress * 100, 100)}%` }} />
|
||||||
className="h-full rounded-full bg-brand-solid"
|
|
||||||
style={{ width: `${Math.min(campaign.budgetProgress * 100, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-1 block text-xs text-quaternary">
|
<span className="mt-1 block text-xs text-quaternary">{Math.round(campaign.budgetProgress * 100)}% budget used</span>
|
||||||
{Math.round(campaign.budgetProgress * 100)}% budget used
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
import { BadgeWithDot } from '@/components/base/badges/badges';
|
import { BadgeWithDot } from "@/components/base/badges/badges";
|
||||||
import { cx } from '@/utils/cx';
|
import type { AuthStatus, IntegrationStatus, LeadIngestionSource } from "@/types/entities";
|
||||||
import type { IntegrationStatus, AuthStatus, LeadIngestionSource } from '@/types/entities';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface IntegrationHealthProps {
|
interface IntegrationHealthProps {
|
||||||
sources: LeadIngestionSource[];
|
sources: LeadIngestionSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusBorderMap: Record<IntegrationStatus, string> = {
|
const statusBorderMap: Record<IntegrationStatus, string> = {
|
||||||
ACTIVE: 'border-secondary',
|
ACTIVE: "border-secondary",
|
||||||
WARNING: 'border-warning',
|
WARNING: "border-warning",
|
||||||
ERROR: 'border-error',
|
ERROR: "border-error",
|
||||||
DISABLED: 'border-secondary',
|
DISABLED: "border-secondary",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusBadgeColorMap: Record<IntegrationStatus, 'success' | 'warning' | 'error' | 'gray'> = {
|
const statusBadgeColorMap: Record<IntegrationStatus, "success" | "warning" | "error" | "gray"> = {
|
||||||
ACTIVE: 'success',
|
ACTIVE: "success",
|
||||||
WARNING: 'warning',
|
WARNING: "warning",
|
||||||
ERROR: 'error',
|
ERROR: "error",
|
||||||
DISABLED: 'gray',
|
DISABLED: "gray",
|
||||||
};
|
};
|
||||||
|
|
||||||
const authBadgeColorMap: Record<AuthStatus, 'success' | 'warning' | 'error' | 'gray'> = {
|
const authBadgeColorMap: Record<AuthStatus, "success" | "warning" | "error" | "gray"> = {
|
||||||
VALID: 'success',
|
VALID: "success",
|
||||||
EXPIRING_SOON: 'warning',
|
EXPIRING_SOON: "warning",
|
||||||
EXPIRED: 'error',
|
EXPIRED: "error",
|
||||||
NOT_CONFIGURED: 'gray',
|
NOT_CONFIGURED: "gray",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatRelativeTime(isoString: string): string {
|
function formatRelativeTime(isoString: string): string {
|
||||||
const diffMs = Date.now() - new Date(isoString).getTime();
|
const diffMs = Date.now() - new Date(isoString).getTime();
|
||||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||||
|
|
||||||
if (diffMinutes < 1) return 'just now';
|
if (diffMinutes < 1) return "just now";
|
||||||
if (diffMinutes < 60) return `${diffMinutes} min ago`;
|
if (diffMinutes < 60) return `${diffMinutes} min ago`;
|
||||||
|
|
||||||
const diffHours = Math.floor(diffMinutes / 60);
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
@@ -48,60 +48,35 @@ export const IntegrationHealth = ({ sources }: IntegrationHealthProps) => {
|
|||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{sources.map((source) => {
|
{sources.map((source) => {
|
||||||
const status = source.integrationStatus ?? 'DISABLED';
|
const status = source.integrationStatus ?? "DISABLED";
|
||||||
const authStatus = source.authStatus ?? 'NOT_CONFIGURED';
|
const authStatus = source.authStatus ?? "NOT_CONFIGURED";
|
||||||
const showAuthBadge = authStatus !== 'VALID';
|
const showAuthBadge = authStatus !== "VALID";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={source.id} className={cx("rounded-xl border bg-primary p-4", statusBorderMap[status])}>
|
||||||
key={source.id}
|
|
||||||
className={cx(
|
|
||||||
'rounded-xl border bg-primary p-4',
|
|
||||||
statusBorderMap[status],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-semibold text-primary">
|
<span className="text-sm font-semibold text-primary">{source.sourceName}</span>
|
||||||
{source.sourceName}
|
<BadgeWithDot size="sm" type="pill-color" color={statusBadgeColorMap[status]}>
|
||||||
</span>
|
|
||||||
<BadgeWithDot
|
|
||||||
size="sm"
|
|
||||||
type="pill-color"
|
|
||||||
color={statusBadgeColorMap[status]}
|
|
||||||
>
|
|
||||||
{status}
|
{status}
|
||||||
</BadgeWithDot>
|
</BadgeWithDot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-xs text-tertiary">
|
<p className="mt-2 text-xs text-tertiary">{source.leadsReceivedLast24h ?? 0} leads in 24h</p>
|
||||||
{source.leadsReceivedLast24h ?? 0} leads in 24h
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{source.lastSuccessfulSyncAt && (
|
{source.lastSuccessfulSyncAt && (
|
||||||
<p className="mt-0.5 text-xs text-quaternary">
|
<p className="mt-0.5 text-xs text-quaternary">Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)}</p>
|
||||||
Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showAuthBadge && (
|
{showAuthBadge && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<BadgeWithDot
|
<BadgeWithDot size="sm" type="pill-color" color={authBadgeColorMap[authStatus]}>
|
||||||
size="sm"
|
Auth: {authStatus.replace(/_/g, " ")}
|
||||||
type="pill-color"
|
|
||||||
color={authBadgeColorMap[authStatus]}
|
|
||||||
>
|
|
||||||
Auth: {authStatus.replace(/_/g, ' ')}
|
|
||||||
</BadgeWithDot>
|
</BadgeWithDot>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(status === 'WARNING' || status === 'ERROR') && source.lastErrorMessage && (
|
{(status === "WARNING" || status === "ERROR") && source.lastErrorMessage && (
|
||||||
<p
|
<p className={cx("mt-2 text-xs", status === "ERROR" ? "text-error-primary" : "text-warning-primary")}>
|
||||||
className={cx(
|
|
||||||
'mt-2 text-xs',
|
|
||||||
status === 'ERROR' ? 'text-error-primary' : 'text-warning-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{source.lastErrorMessage}
|
{source.lastErrorMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
|
import type { Lead } from "@/types/entities";
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from "@/utils/cx";
|
||||||
import type { Lead } from '@/types/entities';
|
|
||||||
|
|
||||||
interface LeadFunnelProps {
|
interface LeadFunnelProps {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
@@ -17,28 +16,24 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
|
|||||||
const stages = useMemo((): FunnelStage[] => {
|
const stages = useMemo((): FunnelStage[] => {
|
||||||
const total = leads.length;
|
const total = leads.length;
|
||||||
|
|
||||||
const contacted = leads.filter((lead) =>
|
const contacted = leads.filter(
|
||||||
lead.leadStatus === 'CONTACTED' ||
|
(lead) =>
|
||||||
lead.leadStatus === 'QUALIFIED' ||
|
lead.leadStatus === "CONTACTED" ||
|
||||||
lead.leadStatus === 'NURTURING' ||
|
lead.leadStatus === "QUALIFIED" ||
|
||||||
lead.leadStatus === 'APPOINTMENT_SET' ||
|
lead.leadStatus === "NURTURING" ||
|
||||||
lead.leadStatus === 'CONVERTED',
|
lead.leadStatus === "APPOINTMENT_SET" ||
|
||||||
|
lead.leadStatus === "CONVERTED",
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const appointmentSet = leads.filter((lead) =>
|
const appointmentSet = leads.filter((lead) => lead.leadStatus === "APPOINTMENT_SET" || lead.leadStatus === "CONVERTED").length;
|
||||||
lead.leadStatus === 'APPOINTMENT_SET' ||
|
|
||||||
lead.leadStatus === 'CONVERTED',
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const converted = leads.filter((lead) =>
|
const converted = leads.filter((lead) => lead.leadStatus === "CONVERTED").length;
|
||||||
lead.leadStatus === 'CONVERTED',
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: 'Generated', count: total, color: 'bg-brand-600' },
|
{ label: "Generated", count: total, color: "bg-brand-600" },
|
||||||
{ label: 'Contacted', count: contacted, color: 'bg-brand-500' },
|
{ label: "Contacted", count: contacted, color: "bg-brand-500" },
|
||||||
{ label: 'Appointment Set', count: appointmentSet, color: 'bg-brand-400' },
|
{ label: "Appointment Set", count: appointmentSet, color: "bg-brand-400" },
|
||||||
{ label: 'Converted', count: converted, color: 'bg-success-500' },
|
{ label: "Converted", count: converted, color: "bg-success-500" },
|
||||||
];
|
];
|
||||||
}, [leads]);
|
}, [leads]);
|
||||||
|
|
||||||
@@ -52,10 +47,7 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
|
|||||||
{stages.map((stage, index) => {
|
{stages.map((stage, index) => {
|
||||||
const widthPercent = maxCount > 0 ? (stage.count / maxCount) * 100 : 0;
|
const widthPercent = maxCount > 0 ? (stage.count / maxCount) * 100 : 0;
|
||||||
const previousCount = index > 0 ? stages[index - 1].count : null;
|
const previousCount = index > 0 ? stages[index - 1].count : null;
|
||||||
const conversionRate =
|
const conversionRate = previousCount !== null && previousCount > 0 ? ((stage.count / previousCount) * 100).toFixed(0) : null;
|
||||||
previousCount !== null && previousCount > 0
|
|
||||||
? ((stage.count / previousCount) * 100).toFixed(0)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={stage.label} className="space-y-1">
|
<div key={stage.label} className="space-y-1">
|
||||||
@@ -63,16 +55,11 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
|
|||||||
<span className="text-xs font-medium text-secondary">{stage.label}</span>
|
<span className="text-xs font-medium text-secondary">{stage.label}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-bold text-primary">{stage.count}</span>
|
<span className="text-sm font-bold text-primary">{stage.count}</span>
|
||||||
{conversionRate !== null && (
|
{conversionRate !== null && <span className="text-xs text-tertiary">({conversionRate}%)</span>}
|
||||||
<span className="text-xs text-tertiary">({conversionRate}%)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-6 w-full overflow-hidden rounded-md bg-secondary">
|
<div className="h-6 w-full overflow-hidden rounded-md bg-secondary">
|
||||||
<div
|
<div className={cx("h-full rounded-md transition-all", stage.color)} style={{ width: `${Math.max(widthPercent, 2)}%` }} />
|
||||||
className={cx('h-full rounded-md transition-all', stage.color)}
|
|
||||||
style={{ width: `${Math.max(widthPercent, 2)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { FC } from 'react';
|
import { type FC, useMemo } from "react";
|
||||||
import { useMemo } from 'react';
|
import { faCircleCheck, faCircleExclamation, faTriangleExclamation } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import type { Lead } from "@/types/entities";
|
||||||
import { faCircleCheck, faTriangleExclamation, faCircleExclamation } from '@fortawesome/pro-duotone-svg-icons';
|
import { cx } from "@/utils/cx";
|
||||||
import { cx } from '@/utils/cx';
|
|
||||||
import type { Lead } from '@/types/entities';
|
|
||||||
|
|
||||||
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
|
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
|
||||||
const AlertTriangle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTriangleExclamation} className={className} />;
|
const AlertTriangle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTriangleExclamation} className={className} />;
|
||||||
@@ -20,7 +18,7 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
|
|||||||
const metrics = useMemo(() => {
|
const metrics = useMemo(() => {
|
||||||
const responseTimes: number[] = [];
|
const responseTimes: number[] = [];
|
||||||
let withinSla = 0;
|
let withinSla = 0;
|
||||||
let total = leads.length;
|
const total = leads.length;
|
||||||
|
|
||||||
for (const lead of leads) {
|
for (const lead of leads) {
|
||||||
if (lead.createdAt && lead.firstContactedAt) {
|
if (lead.createdAt && lead.firstContactedAt) {
|
||||||
@@ -36,39 +34,40 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
|
|||||||
// Leads without firstContactedAt are counted as outside SLA
|
// Leads without firstContactedAt are counted as outside SLA
|
||||||
}
|
}
|
||||||
|
|
||||||
const avgHours =
|
const avgHours = responseTimes.length > 0 ? responseTimes.reduce((sum, h) => sum + h, 0) / responseTimes.length : 0;
|
||||||
responseTimes.length > 0
|
|
||||||
? responseTimes.reduce((sum, h) => sum + h, 0) / responseTimes.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const slaPercent = total > 0 ? (withinSla / total) * 100 : 0;
|
const slaPercent = total > 0 ? (withinSla / total) * 100 : 0;
|
||||||
|
|
||||||
return { avgHours, withinSla, total, slaPercent };
|
return { avgHours, withinSla, total, slaPercent };
|
||||||
}, [leads]);
|
}, [leads]);
|
||||||
|
|
||||||
const getTargetStatus = (): { icon: FC<{ className?: string }>; label: string; colorClass: string } => {
|
const getTargetStatus = (): {
|
||||||
|
icon: FC<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
colorClass: string;
|
||||||
|
} => {
|
||||||
const diff = metrics.avgHours - SLA_TARGET_HOURS;
|
const diff = metrics.avgHours - SLA_TARGET_HOURS;
|
||||||
|
|
||||||
if (diff <= 0) {
|
if (diff <= 0) {
|
||||||
return {
|
return {
|
||||||
icon: CheckCircle,
|
icon: CheckCircle,
|
||||||
label: 'Below target',
|
label: "Below target",
|
||||||
colorClass: 'text-success-primary',
|
colorClass: "text-success-primary",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diff <= 0.5) {
|
if (diff <= 0.5) {
|
||||||
return {
|
return {
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
label: 'Near target',
|
label: "Near target",
|
||||||
colorClass: 'text-warning-primary',
|
colorClass: "text-warning-primary",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icon: AlertCircle,
|
icon: AlertCircle,
|
||||||
label: 'Above target',
|
label: "Above target",
|
||||||
colorClass: 'text-error-primary',
|
colorClass: "text-error-primary",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,18 +79,14 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
|
|||||||
<h3 className="text-sm font-bold text-primary">Response SLA</h3>
|
<h3 className="text-sm font-bold text-primary">Response SLA</h3>
|
||||||
|
|
||||||
<div className="mt-4 flex items-end gap-3">
|
<div className="mt-4 flex items-end gap-3">
|
||||||
<span className="text-display-sm font-bold text-primary">
|
<span className="text-display-sm font-bold text-primary">{metrics.avgHours.toFixed(1)}h</span>
|
||||||
{metrics.avgHours.toFixed(1)}h
|
|
||||||
</span>
|
|
||||||
<div className="mb-1 flex items-center gap-1">
|
<div className="mb-1 flex items-center gap-1">
|
||||||
<span className="text-xs text-tertiary">Target: {SLA_TARGET_HOURS}h</span>
|
<span className="text-xs text-tertiary">Target: {SLA_TARGET_HOURS}h</span>
|
||||||
<StatusIcon className={cx('size-4', status.colorClass)} />
|
<StatusIcon className={cx("size-4", status.colorClass)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className={cx('mt-1 block text-xs font-medium', status.colorClass)}>
|
<span className={cx("mt-1 block text-xs font-medium", status.colorClass)}>{status.label}</span>
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="flex items-center justify-between text-xs text-tertiary">
|
<div className="flex items-center justify-between text-xs text-tertiary">
|
||||||
@@ -101,18 +96,15 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
|
|||||||
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-tertiary">
|
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-tertiary">
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'h-full rounded-full transition-all',
|
"h-full rounded-full transition-all",
|
||||||
metrics.slaPercent >= 80
|
metrics.slaPercent >= 80 ? "bg-success-500" : metrics.slaPercent >= 60 ? "bg-warning-500" : "bg-error-500",
|
||||||
? 'bg-success-500'
|
|
||||||
: metrics.slaPercent >= 60
|
|
||||||
? 'bg-warning-500'
|
|
||||||
: 'bg-error-500',
|
|
||||||
)}
|
)}
|
||||||
style={{ width: `${metrics.slaPercent}%` }}
|
style={{ width: `${metrics.slaPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-1.5 block text-xs text-tertiary">
|
<span className="mt-1.5 block text-xs text-tertiary">
|
||||||
{metrics.withinSla} of {metrics.total} leads within SLA ({Math.round(metrics.slaPercent)}%)
|
{metrics.withinSla} of {metrics.total} leads within SLA ({Math.round(metrics.slaPercent)}
|
||||||
|
%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { Avatar } from '@/components/base/avatar/avatar';
|
import { BadgeWithDot } from "@/components/base/badges/badges";
|
||||||
import { BadgeWithDot } from '@/components/base/badges/badges';
|
import type { Agent, Call, Lead } from "@/types/entities";
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from "@/utils/cx";
|
||||||
import type { Lead, Call, Agent } from '@/types/entities';
|
|
||||||
|
|
||||||
interface TeamScoreboardProps {
|
interface TeamScoreboardProps {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
@@ -25,16 +24,14 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
|
|||||||
const leadsProcessed = leads.filter((lead) => lead.assignedAgent === agentName).length;
|
const leadsProcessed = leads.filter((lead) => lead.assignedAgent === agentName).length;
|
||||||
const agentCalls = calls.filter((call) => call.agentName === agentName);
|
const agentCalls = calls.filter((call) => call.agentName === agentName);
|
||||||
const callsMade = agentCalls.length;
|
const callsMade = agentCalls.length;
|
||||||
const appointmentsBooked = agentCalls.filter(
|
const appointmentsBooked = agentCalls.filter((call) => call.disposition === "APPOINTMENT_BOOKED").length;
|
||||||
(call) => call.disposition === 'APPOINTMENT_BOOKED',
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return { agent, leadsProcessed, callsMade, appointmentsBooked };
|
return { agent, leadsProcessed, callsMade, appointmentsBooked };
|
||||||
});
|
});
|
||||||
}, [leads, calls, agents]);
|
}, [leads, calls, agents]);
|
||||||
|
|
||||||
const bestPerformerId = useMemo(() => {
|
const bestPerformerId = useMemo(() => {
|
||||||
let bestId = '';
|
let bestId = "";
|
||||||
let maxAppointments = -1;
|
let maxAppointments = -1;
|
||||||
|
|
||||||
for (const stat of agentStats) {
|
for (const stat of agentStats) {
|
||||||
@@ -56,29 +53,19 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
|
|||||||
<div
|
<div
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex min-w-[260px] flex-1 flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5',
|
"flex min-w-[260px] flex-1 flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5",
|
||||||
isBest && 'ring-2 ring-success-600',
|
isBest && "ring-2 ring-success-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar
|
<Avatar initials={agent.initials ?? undefined} size="md" status={agent.isOnShift ? "online" : "offline"} />
|
||||||
initials={agent.initials ?? undefined}
|
|
||||||
size="md"
|
|
||||||
status={agent.isOnShift ? 'online' : 'offline'}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<span className="text-sm font-semibold text-primary">{agent.name}</span>
|
<span className="text-sm font-semibold text-primary">{agent.name}</span>
|
||||||
<BadgeWithDot
|
<BadgeWithDot size="sm" type="pill-color" color={agent.isOnShift ? "success" : "gray"}>
|
||||||
size="sm"
|
{agent.isOnShift ? "On Shift" : "Off Shift"}
|
||||||
type="pill-color"
|
|
||||||
color={agent.isOnShift ? 'success' : 'gray'}
|
|
||||||
>
|
|
||||||
{agent.isOnShift ? 'On Shift' : 'Off Shift'}
|
|
||||||
</BadgeWithDot>
|
</BadgeWithDot>
|
||||||
</div>
|
</div>
|
||||||
{isBest && (
|
{isBest && <span className="text-xs font-medium text-success-primary">Top</span>}
|
||||||
<span className="text-xs font-medium text-success-primary">Top</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -96,9 +83,7 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs text-tertiary">Avg Response</span>
|
<span className="text-xs text-tertiary">Avg Response</span>
|
||||||
<span className="text-lg font-bold text-primary">
|
<span className="text-lg font-bold text-primary">{agent.avgResponseHours ?? "—"}h</span>
|
||||||
{agent.avgResponseHours ?? '—'}h
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { faBars, faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faXmark, faBars } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import {
|
import {
|
||||||
Button as AriaButton,
|
Button as AriaButton,
|
||||||
Dialog as AriaDialog,
|
Dialog as AriaDialog,
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ export type NavItemType = {
|
|||||||
/** URL to navigate to when the nav item is clicked. */
|
/** URL to navigate to when the nav item is clicked. */
|
||||||
href?: string;
|
href?: string;
|
||||||
/** Icon component to display. */
|
/** Icon component to display. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
icon?: FC<Record<string, any>>;
|
icon?: FC<Record<string, any>>;
|
||||||
/** Badge to display. */
|
/** Badge to display. */
|
||||||
badge?: ReactNode;
|
badge?: ReactNode;
|
||||||
/** List of sub-items to display. */
|
/** List of sub-items to display. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
items?: { label: string; href: string; icon?: FC<Record<string, any>>; badge?: ReactNode }[];
|
items?: { label: string; href: string; icon?: FC<Record<string, any>>; badge?: ReactNode }[];
|
||||||
/** Whether this nav item is a divider. */
|
/** Whether this nav item is a divider. */
|
||||||
divider?: boolean;
|
divider?: boolean;
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
|
import { faBell, faGear, faLifeRing, faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faBell, faLifeRing, faMagnifyingGlass, faGear } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
|
|
||||||
const Bell01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBell} className={className} />;
|
|
||||||
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
|
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
|
||||||
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
|
||||||
import { Button as AriaButton, DialogTrigger, Popover } from "react-aria-components";
|
import { Button as AriaButton, DialogTrigger, Popover } from "react-aria-components";
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { BadgeWithDot } from "@/components/base/badges/badges";
|
import { BadgeWithDot } from "@/components/base/badges/badges";
|
||||||
@@ -18,6 +13,11 @@ import { NavItemBase } from "./base-components/nav-item";
|
|||||||
import { NavItemButton } from "./base-components/nav-item-button";
|
import { NavItemButton } from "./base-components/nav-item-button";
|
||||||
import { NavList } from "./base-components/nav-list";
|
import { NavList } from "./base-components/nav-list";
|
||||||
|
|
||||||
|
const Bell01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBell} className={className} />;
|
||||||
|
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
|
||||||
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
|
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
/** Label text for the nav item. */
|
/** Label text for the nav item. */
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import type { FC, ReactNode } from "react";
|
import { type FC, type ReactNode, useState } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Input } from "@/components/base/input/input";
|
||||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||||
@@ -14,6 +11,8 @@ import { NavItemBase } from "../base-components/nav-item";
|
|||||||
import { NavList } from "../base-components/nav-list";
|
import { NavList } from "../base-components/nav-list";
|
||||||
import type { NavItemType } from "../config";
|
import type { NavItemType } from "../config";
|
||||||
|
|
||||||
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
|
|
||||||
interface SidebarNavigationDualTierProps {
|
interface SidebarNavigationDualTierProps {
|
||||||
/** URL of the currently active item. */
|
/** URL of the currently active item. */
|
||||||
activeUrl?: string;
|
activeUrl?: string;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Input } from "@/components/base/input/input";
|
||||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||||
import { MobileNavigationHeader } from "../base-components/mobile-header";
|
import { MobileNavigationHeader } from "../base-components/mobile-header";
|
||||||
@@ -10,6 +8,8 @@ import { NavAccountCard } from "../base-components/nav-account-card";
|
|||||||
import { NavList } from "../base-components/nav-list";
|
import { NavList } from "../base-components/nav-list";
|
||||||
import type { NavItemDividerType, NavItemType } from "../config";
|
import type { NavItemDividerType, NavItemType } from "../config";
|
||||||
|
|
||||||
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
|
|
||||||
interface SidebarNavigationSectionDividersProps {
|
interface SidebarNavigationSectionDividersProps {
|
||||||
/** URL of the currently active item. */
|
/** URL of the currently active item. */
|
||||||
activeUrl?: string;
|
activeUrl?: string;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Input } from "@/components/base/input/input";
|
||||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
@@ -12,6 +10,8 @@ import { NavItemBase } from "../base-components/nav-item";
|
|||||||
import { NavList } from "../base-components/nav-list";
|
import { NavList } from "../base-components/nav-list";
|
||||||
import type { NavItemType } from "../config";
|
import type { NavItemType } from "../config";
|
||||||
|
|
||||||
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
|
|
||||||
interface SidebarNavigationProps {
|
interface SidebarNavigationProps {
|
||||||
/** URL of the currently active item. */
|
/** URL of the currently active item. */
|
||||||
activeUrl?: string;
|
activeUrl?: string;
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import type { FC } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { useState } from "react";
|
import { faArrowRightFromBracket, faGear, faLifeRing } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faLifeRing, faArrowRightFromBracket, faGear } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
|
|
||||||
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
|
|
||||||
const LogOut01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
|
|
||||||
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { Button as AriaButton, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
import { Button as AriaButton, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
@@ -22,6 +17,10 @@ import { NavItemButton } from "../base-components/nav-item-button";
|
|||||||
import { NavList } from "../base-components/nav-list";
|
import { NavList } from "../base-components/nav-list";
|
||||||
import type { NavItemType } from "../config";
|
import type { NavItemType } from "../config";
|
||||||
|
|
||||||
|
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
|
||||||
|
const LogOut01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
|
||||||
|
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
||||||
|
|
||||||
interface SidebarNavigationSlimProps {
|
interface SidebarNavigationSlimProps {
|
||||||
/** URL of the currently active item. */
|
/** URL of the currently active item. */
|
||||||
activeUrl?: string;
|
activeUrl?: string;
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
|
import { type FC, Fragment, type HTMLAttributes, type PropsWithChildren, useContext, useState } from "react";
|
||||||
import { Fragment, useContext, useState } from "react";
|
|
||||||
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
|
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
|
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
|
||||||
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
|
|
||||||
import type { CalendarProps as AriaCalendarProps, DateValue } from "react-aria-components";
|
|
||||||
import {
|
import {
|
||||||
Calendar as AriaCalendar,
|
Calendar as AriaCalendar,
|
||||||
CalendarContext as AriaCalendarContext,
|
CalendarContext as AriaCalendarContext,
|
||||||
@@ -14,8 +9,10 @@ import {
|
|||||||
CalendarGridBody as AriaCalendarGridBody,
|
CalendarGridBody as AriaCalendarGridBody,
|
||||||
CalendarGridHeader as AriaCalendarGridHeader,
|
CalendarGridHeader as AriaCalendarGridHeader,
|
||||||
CalendarHeaderCell as AriaCalendarHeaderCell,
|
CalendarHeaderCell as AriaCalendarHeaderCell,
|
||||||
|
type CalendarProps as AriaCalendarProps,
|
||||||
CalendarStateContext as AriaCalendarStateContext,
|
CalendarStateContext as AriaCalendarStateContext,
|
||||||
Heading as AriaHeading,
|
Heading as AriaHeading,
|
||||||
|
type DateValue,
|
||||||
useSlottedContext,
|
useSlottedContext,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
@@ -23,6 +20,9 @@ import { cx } from "@/utils/cx";
|
|||||||
import { CalendarCell } from "./cell";
|
import { CalendarCell } from "./cell";
|
||||||
import { DateInput } from "./date-input";
|
import { DateInput } from "./date-input";
|
||||||
|
|
||||||
|
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
|
||||||
|
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
|
||||||
|
|
||||||
export const CalendarContextProvider = ({ children }: PropsWithChildren) => {
|
export const CalendarContextProvider = ({ children }: PropsWithChildren) => {
|
||||||
const [value, onChange] = useState<DateValue | null>(null);
|
const [value, onChange] = useState<DateValue | null>(null);
|
||||||
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { getDayOfWeek, getLocalTimeZone, isToday } from "@internationalized/date";
|
import { getDayOfWeek, getLocalTimeZone, isToday } from "@internationalized/date";
|
||||||
import type { CalendarCellProps as AriaCalendarCellProps } from "react-aria-components";
|
import {
|
||||||
import { CalendarCell as AriaCalendarCell, RangeCalendarContext, useLocale, useSlottedContext } from "react-aria-components";
|
CalendarCell as AriaCalendarCell,
|
||||||
|
type CalendarCellProps as AriaCalendarCellProps,
|
||||||
|
RangeCalendarContext,
|
||||||
|
useLocale,
|
||||||
|
useSlottedContext,
|
||||||
|
} from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface CalendarCellProps extends AriaCalendarCellProps {
|
interface CalendarCellProps extends AriaCalendarCellProps {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { DateInputProps as AriaDateInputProps } from "react-aria-components";
|
import { DateInput as AriaDateInput, type DateInputProps as AriaDateInputProps, DateSegment as AriaDateSegment } from "react-aria-components";
|
||||||
import { DateInput as AriaDateInput, DateSegment as AriaDateSegment } from "react-aria-components";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface DateInputProps extends Omit<AriaDateInputProps, "children"> {}
|
type DateInputProps = Omit<AriaDateInputProps, "children">;
|
||||||
|
|
||||||
export const DateInput = (props: DateInputProps) => {
|
export const DateInput = (props: DateInputProps) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { getLocalTimeZone, today } from "@internationalized/date";
|
import { getLocalTimeZone, today } from "@internationalized/date";
|
||||||
import { useControlledState } from "@react-stately/utils";
|
import { useControlledState } from "@react-stately/utils";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
|
|
||||||
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
|
|
||||||
import { useDateFormatter } from "react-aria";
|
import { useDateFormatter } from "react-aria";
|
||||||
import type { DatePickerProps as AriaDatePickerProps, DateValue } from "react-aria-components";
|
import {
|
||||||
import { DatePicker as AriaDatePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover } from "react-aria-components";
|
DatePicker as AriaDatePicker,
|
||||||
|
type DatePickerProps as AriaDatePickerProps,
|
||||||
|
Dialog as AriaDialog,
|
||||||
|
Group as AriaGroup,
|
||||||
|
Popover as AriaPopover,
|
||||||
|
type DateValue,
|
||||||
|
} from "react-aria-components";
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { Calendar } from "./calendar";
|
import { Calendar } from "./calendar";
|
||||||
|
|
||||||
|
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
|
||||||
|
|
||||||
const highlightedDates = [today(getLocalTimeZone())];
|
const highlightedDates = [today(getLocalTimeZone())];
|
||||||
|
|
||||||
interface DatePickerProps extends AriaDatePickerProps<DateValue> {
|
interface DatePickerProps extends AriaDatePickerProps<DateValue> {
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import type { FC } from "react";
|
import { type FC, useMemo, useState } from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { endOfMonth, endOfWeek, getLocalTimeZone, startOfMonth, startOfWeek, today } from "@internationalized/date";
|
import { endOfMonth, endOfWeek, getLocalTimeZone, startOfMonth, startOfWeek, today } from "@internationalized/date";
|
||||||
import { useControlledState } from "@react-stately/utils";
|
import { useControlledState } from "@react-stately/utils";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
|
|
||||||
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
|
|
||||||
import { useDateFormatter } from "react-aria";
|
import { useDateFormatter } from "react-aria";
|
||||||
import type { DateRangePickerProps as AriaDateRangePickerProps, DateValue } from "react-aria-components";
|
import {
|
||||||
import { DateRangePicker as AriaDateRangePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover, useLocale } from "react-aria-components";
|
DateRangePicker as AriaDateRangePicker,
|
||||||
|
type DateRangePickerProps as AriaDateRangePickerProps,
|
||||||
|
Dialog as AriaDialog,
|
||||||
|
Group as AriaGroup,
|
||||||
|
Popover as AriaPopover,
|
||||||
|
type DateValue,
|
||||||
|
useLocale,
|
||||||
|
} from "react-aria-components";
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { DateInput } from "./date-input";
|
import { DateInput } from "./date-input";
|
||||||
import { RangeCalendar } from "./range-calendar";
|
import { RangeCalendar } from "./range-calendar";
|
||||||
import { RangePresetButton } from "./range-preset";
|
import { RangePresetButton } from "./range-preset";
|
||||||
|
|
||||||
|
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
|
||||||
|
|
||||||
const now = today(getLocalTimeZone());
|
const now = today(getLocalTimeZone());
|
||||||
|
|
||||||
const highlightedDates = [today(getLocalTimeZone())];
|
const highlightedDates = [today(getLocalTimeZone())];
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
|
import { type FC, Fragment, type HTMLAttributes, type PropsWithChildren, useContext, useState } from "react";
|
||||||
import { Fragment, useContext, useState } from "react";
|
|
||||||
import type { CalendarDate } from "@internationalized/date";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
|
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
|
import type { CalendarDate } from "@internationalized/date";
|
||||||
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
|
|
||||||
import { useDateFormatter } from "react-aria";
|
import { useDateFormatter } from "react-aria";
|
||||||
import type { RangeCalendarProps as AriaRangeCalendarProps, DateValue } from "react-aria-components";
|
|
||||||
import {
|
import {
|
||||||
CalendarGrid as AriaCalendarGrid,
|
CalendarGrid as AriaCalendarGrid,
|
||||||
CalendarGridBody as AriaCalendarGridBody,
|
CalendarGridBody as AriaCalendarGridBody,
|
||||||
CalendarGridHeader as AriaCalendarGridHeader,
|
CalendarGridHeader as AriaCalendarGridHeader,
|
||||||
CalendarHeaderCell as AriaCalendarHeaderCell,
|
CalendarHeaderCell as AriaCalendarHeaderCell,
|
||||||
RangeCalendar as AriaRangeCalendar,
|
RangeCalendar as AriaRangeCalendar,
|
||||||
|
type RangeCalendarProps as AriaRangeCalendarProps,
|
||||||
|
type DateValue,
|
||||||
RangeCalendarContext,
|
RangeCalendarContext,
|
||||||
RangeCalendarStateContext,
|
RangeCalendarStateContext,
|
||||||
useSlottedContext,
|
useSlottedContext,
|
||||||
@@ -23,6 +20,9 @@ import { useBreakpoint } from "@/hooks/use-breakpoint";
|
|||||||
import { CalendarCell } from "./cell";
|
import { CalendarCell } from "./cell";
|
||||||
import { DateInput } from "./date-input";
|
import { DateInput } from "./date-input";
|
||||||
|
|
||||||
|
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
|
||||||
|
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
|
||||||
|
|
||||||
export const RangeCalendarContextProvider = ({ children }: PropsWithChildren) => {
|
export const RangeCalendarContextProvider = ({ children }: PropsWithChildren) => {
|
||||||
const [value, onChange] = useState<{ start: DateValue; end: DateValue } | null>(null);
|
const [value, onChange] = useState<{ start: DateValue; end: DateValue } | null>(null);
|
||||||
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
|
import { Children, type ComponentProps, type ComponentPropsWithRef, type FC, createContext, isValidElement, useContext } from "react";
|
||||||
import { Children, createContext, isValidElement, useContext } from "react";
|
|
||||||
import { FileIcon } from "@untitledui/file-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
import { FileIcon } from "@untitledui/file-icons";
|
||||||
import { FeaturedIcon as FeaturedIconbase } from "@/components/foundations/featured-icon/featured-icon";
|
import { FeaturedIcon as FeaturedIconbase } from "@/components/foundations/featured-icon/featured-icon";
|
||||||
import type { BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
|
import { BackgroundPattern, type BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
|
||||||
import { BackgroundPattern } from "@/components/shared-assets/background-patterns";
|
|
||||||
import { Illustration as Illustrations } from "@/components/shared-assets/illustrations";
|
import { Illustration as Illustrations } from "@/components/shared-assets/illustrations";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
|
|
||||||
interface RootContextProps {
|
interface RootContextProps {
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
|
import { type ComponentProps, type ComponentPropsWithRef, type FC, useId, useRef, useState } from "react";
|
||||||
import { useId, useRef, useState } from "react";
|
import { faCircleCheck, faCircleXmark, faCloudArrowUp, faTrash } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import type { FileIcon } from "@untitledui/file-icons";
|
|
||||||
import { FileIcon as FileTypeIcon } from "@untitledui/file-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faCircleCheck, faTrash, faCloudArrowUp, faCircleXmark } from "@fortawesome/pro-duotone-svg-icons";
|
import { type FileIcon, FileIcon as FileTypeIcon } from "@untitledui/file-icons";
|
||||||
|
|
||||||
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { ButtonUtility } from "@/components/base/buttons/button-utility";
|
import { ButtonUtility } from "@/components/base/buttons/button-utility";
|
||||||
@@ -13,11 +9,14 @@ import { ProgressBar } from "@/components/base/progress-indicators/progress-indi
|
|||||||
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a human-readable file size.
|
* Returns a human-readable file size.
|
||||||
* @param bytes - The size of the file in bytes.
|
* @param bytes - The size of the file in bytes.
|
||||||
* @returns A string representing the file size in a human-readable format.
|
* @returns A string representing the file size in a human-readable format.
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const getReadableFileSize = (bytes: number) => {
|
export const getReadableFileSize = (bytes: number) => {
|
||||||
if (bytes === 0) return "0 KB";
|
if (bytes === 0) return "0 KB";
|
||||||
|
|
||||||
@@ -388,6 +387,7 @@ const FileUploadList = (props: ComponentPropsWithRef<"ul">) => (
|
|||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const FileUpload = {
|
export const FileUpload = {
|
||||||
Root: FileUploadRoot,
|
Root: FileUploadRoot,
|
||||||
List: FileUploadList,
|
List: FileUploadList,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { DialogProps as AriaDialogProps, ModalOverlayProps as AriaModalOverlayProps } from "react-aria-components";
|
import {
|
||||||
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
|
Dialog as AriaDialog,
|
||||||
|
type DialogProps as AriaDialogProps,
|
||||||
|
DialogTrigger as AriaDialogTrigger,
|
||||||
|
Modal as AriaModal,
|
||||||
|
ModalOverlay as AriaModalOverlay,
|
||||||
|
type ModalOverlayProps as AriaModalOverlayProps,
|
||||||
|
} from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
export const DialogTrigger = AriaDialogTrigger;
|
export const DialogTrigger = AriaDialogTrigger;
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { faCircleCheck, faCircleExclamation, faCircleInfo } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faCircleExclamation, faCircleCheck, faCircleInfo } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
|
|
||||||
const AlertCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleExclamation} className={className} />;
|
|
||||||
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
|
|
||||||
const InfoCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleInfo} className={className} />;
|
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { CloseButton } from "@/components/base/buttons/close-button";
|
import { CloseButton } from "@/components/base/buttons/close-button";
|
||||||
@@ -12,6 +8,10 @@ import { ProgressBar } from "@/components/base/progress-indicators/progress-indi
|
|||||||
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const AlertCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleExclamation} className={className} />;
|
||||||
|
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
|
||||||
|
const InfoCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleInfo} className={className} />;
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
default: InfoCircle,
|
default: InfoCircle,
|
||||||
brand: InfoCircle,
|
brand: InfoCircle,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ToasterProps } from "sonner";
|
import { Toaster as SonnerToaster, type ToasterProps, useSonner } from "sonner";
|
||||||
import { Toaster as SonnerToaster, useSonner } from "sonner";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
export const DEFAULT_TOAST_POSITION = "bottom-right";
|
export const DEFAULT_TOAST_POSITION = "bottom-right";
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { CSSProperties, FC, HTMLAttributes, ReactNode } from "react";
|
import React, {
|
||||||
import React, { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useState } from "react";
|
type CSSProperties,
|
||||||
|
type FC,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
cloneElement,
|
||||||
|
createContext,
|
||||||
|
isValidElement,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
type PaginationPage = {
|
type PaginationPage = {
|
||||||
/** The type of the pagination item. */
|
/** The type of the pagination item. */
|
||||||
@@ -46,9 +56,8 @@ export interface PaginationRootProps {
|
|||||||
onPageChange?: (page: number) => void;
|
onPageChange?: (page: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
|
const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
|
||||||
const [pages, setPages] = useState<PaginationItemType[]>([]);
|
|
||||||
|
|
||||||
const createPaginationItems = useCallback((): PaginationItemType[] => {
|
const createPaginationItems = useCallback((): PaginationItemType[] => {
|
||||||
const items: PaginationItemType[] = [];
|
const items: PaginationItemType[] = [];
|
||||||
// Calculate the maximum number of pagination elements (pages, potential ellipsis, first and last) to show
|
// Calculate the maximum number of pagination elements (pages, potential ellipsis, first and last) to show
|
||||||
@@ -150,10 +159,7 @@ const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children,
|
|||||||
return items;
|
return items;
|
||||||
}, [total, siblingCount, page]);
|
}, [total, siblingCount, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
const pages = useMemo(() => createPaginationItems(), [createPaginationItems]);
|
||||||
const paginationItems = createPaginationItems();
|
|
||||||
setPages(paginationItems);
|
|
||||||
}, [createPaginationItems]);
|
|
||||||
|
|
||||||
const onPageChangeHandler = (newPage: number) => {
|
const onPageChangeHandler = (newPage: number) => {
|
||||||
onPageChange?.(newPage);
|
onPageChange?.(newPage);
|
||||||
@@ -207,6 +213,7 @@ interface TriggerProps {
|
|||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
|
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
|
||||||
const context = useContext(PaginationContext);
|
const context = useContext(PaginationContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -252,8 +259,10 @@ const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const PaginationPrevTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="prev" />;
|
const PaginationPrevTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="prev" />;
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
|
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
|
||||||
|
|
||||||
interface PaginationItemRenderProps {
|
interface PaginationItemRenderProps {
|
||||||
@@ -281,6 +290,7 @@ export interface PaginationItemProps {
|
|||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
|
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
|
||||||
const context = useContext(PaginationContext);
|
const context = useContext(PaginationContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -343,6 +353,7 @@ interface PaginationEllipsisProps {
|
|||||||
className?: string | (() => string);
|
className?: string | (() => string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
|
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
|
||||||
const computedClassName = typeof className === "function" ? className() : className;
|
const computedClassName = typeof className === "function" ? className() : className;
|
||||||
|
|
||||||
@@ -357,6 +368,7 @@ interface PaginationContextComponentProps {
|
|||||||
children: (pagination: PaginationContextType) => ReactNode;
|
children: (pagination: PaginationContextType) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
|
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
|
||||||
const context = useContext(PaginationContext);
|
const context = useContext(PaginationContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import type { PaginationRootProps } from "./pagination-base";
|
import { Pagination, type PaginationRootProps } from "./pagination-base";
|
||||||
import { Pagination } from "./pagination-base";
|
|
||||||
|
|
||||||
interface PaginationDotProps extends Omit<PaginationRootProps, "children"> {
|
interface PaginationDotProps extends Omit<PaginationRootProps, "children"> {
|
||||||
/** The size of the pagination dot. */
|
/** The size of the pagination dot. */
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import type { PaginationRootProps } from "./pagination-base";
|
import { Pagination, type PaginationRootProps } from "./pagination-base";
|
||||||
import { Pagination } from "./pagination-base";
|
|
||||||
|
|
||||||
interface PaginationLineProps extends Omit<PaginationRootProps, "children"> {
|
interface PaginationLineProps extends Omit<PaginationRootProps, "children"> {
|
||||||
/** The size of the pagination line. */
|
/** The size of the pagination line. */
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { type ComponentPropsWithRef, type ReactNode, type RefAttributes } from "react";
|
import { type ComponentPropsWithRef, type ReactNode, type RefAttributes } from "react";
|
||||||
import type {
|
import {
|
||||||
DialogProps as AriaDialogProps,
|
Dialog as AriaDialog,
|
||||||
ModalOverlayProps as AriaModalOverlayProps,
|
type DialogProps as AriaDialogProps,
|
||||||
ModalRenderProps as AriaModalRenderProps,
|
DialogTrigger as AriaDialogTrigger,
|
||||||
|
Modal as AriaModal,
|
||||||
|
ModalOverlay as AriaModalOverlay,
|
||||||
|
type ModalOverlayProps as AriaModalOverlayProps,
|
||||||
|
type ModalRenderProps as AriaModalRenderProps,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
|
|
||||||
import { CloseButton } from "@/components/base/buttons/close-button";
|
import { CloseButton } from "@/components/base/buttons/close-button";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { ComponentPropsWithRef, ReactNode } from "react";
|
import { type ComponentPropsWithRef, Fragment, type ReactNode, createContext, useContext } from "react";
|
||||||
import { Fragment, createContext, useContext } from "react";
|
import {
|
||||||
import type { TabListProps as AriaTabListProps, TabProps as AriaTabProps, TabRenderProps as AriaTabRenderProps } from "react-aria-components";
|
Tab as AriaTab,
|
||||||
import { Tab as AriaTab, TabList as AriaTabList, TabPanel as AriaTabPanel, Tabs as AriaTabs, TabsContext, useSlottedContext } from "react-aria-components";
|
TabList as AriaTabList,
|
||||||
|
type TabListProps as AriaTabListProps,
|
||||||
|
TabPanel as AriaTabPanel,
|
||||||
|
type TabProps as AriaTabProps,
|
||||||
|
type TabRenderProps as AriaTabRenderProps,
|
||||||
|
Tabs as AriaTabs,
|
||||||
|
TabsContext,
|
||||||
|
useSlottedContext,
|
||||||
|
} from "react-aria-components";
|
||||||
import type { BadgeColors } from "@/components/base/badges/badge-types";
|
import type { BadgeColors } from "@/components/base/badges/badge-types";
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
|
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { type AvatarProps } from "./avatar";
|
import { type AvatarProps } from "./avatar";
|
||||||
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type FC, type ReactNode, useState } from "react";
|
import { type FC, type ReactNode, useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
|
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faPlus } from "@fortawesome/pro-duotone-svg-icons";
|
import { faPlus } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import type { ButtonProps as AriaButtonProps } from "react-aria-components";
|
import type { ButtonProps as AriaButtonProps } from "react-aria-components";
|
||||||
import { Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "@/components/base/tooltip/tooltip";
|
import { Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
export * from "./avatar-add-button";
|
export * from "./avatar-add-button";
|
||||||
export * from "./avatar-company-icon";
|
export * from "./avatar-company-icon";
|
||||||
export * from "./avatar-online-indicator";
|
export * from "./avatar-online-indicator";
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { FC, ReactNode } from "react";
|
import { type FC, type ReactNode, isValidElement } from "react";
|
||||||
import { isValidElement } from "react";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
|
||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
||||||
|
|
||||||
type Size = "md" | "lg";
|
type Size = "md" | "lg";
|
||||||
type Color = "brand" | "warning" | "error" | "gray" | "success";
|
type Color = "brand" | "warning" | "error" | "gray" | "success";
|
||||||
type Theme = "light" | "modern";
|
type Theme = "light" | "modern";
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { FC, MouseEventHandler, ReactNode } from "react";
|
import type { FC, MouseEventHandler, ReactNode } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
|
|
||||||
import { Dot } from "@/components/foundations/dot-icon";
|
import { Dot } from "@/components/foundations/dot-icon";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import type { BadgeColors, BadgeTypeToColorMap, BadgeTypes, FlagTypes, IconComponentType, Sizes } from "./badge-types";
|
import { type BadgeColors, type BadgeTypeToColorMap, type BadgeTypes, type FlagTypes, type IconComponentType, type Sizes, badgeTypes } from "./badge-types";
|
||||||
import { badgeTypes } from "./badge-types";
|
|
||||||
|
|
||||||
|
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const filledColors: Record<BadgeColors, { root: string; addon: string; addonButton: string }> = {
|
export const filledColors: Record<BadgeColors, { root: string; addon: string; addonButton: string }> = {
|
||||||
gray: {
|
gray: {
|
||||||
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",
|
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const styles = sortCx({
|
export const styles = sortCx({
|
||||||
common: {
|
common: {
|
||||||
root: [
|
root: [
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
|
import { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type DetailedHTMLProps, type FC, type ReactNode, isValidElement } from "react";
|
||||||
import { isValidElement } from "react";
|
|
||||||
import type { Placement } from "react-aria";
|
import type { Placement } from "react-aria";
|
||||||
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
|
||||||
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
|
||||||
import { Tooltip } from "@/components/base/tooltip/tooltip";
|
import { Tooltip } from "@/components/base/tooltip/tooltip";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const styles = {
|
export const styles = {
|
||||||
secondary:
|
secondary:
|
||||||
"bg-primary text-fg-quaternary shadow-xs-skeumorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:shadow-xs disabled:ring-disabled_subtle",
|
"bg-primary text-fg-quaternary shadow-xs-skeumorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:shadow-xs disabled:ring-disabled_subtle",
|
||||||
@@ -63,27 +62,20 @@ export const ButtonUtility = ({
|
|||||||
const href = "href" in otherProps ? otherProps.href : undefined;
|
const href = "href" in otherProps ? otherProps.href : undefined;
|
||||||
const Component = href ? AriaLink : AriaButton;
|
const Component = href ? AriaLink : AriaButton;
|
||||||
|
|
||||||
let props = {};
|
const props = href
|
||||||
|
? {
|
||||||
if (href) {
|
|
||||||
props = {
|
|
||||||
...otherProps,
|
...otherProps,
|
||||||
|
|
||||||
href: isDisabled ? undefined : href,
|
href: isDisabled ? undefined : href,
|
||||||
|
|
||||||
// Since anchor elements do not support the `disabled` attribute and state,
|
// Since anchor elements do not support the `disabled` attribute and state,
|
||||||
// we need to specify `data-rac` and `data-disabled` in order to be able
|
// we need to specify `data-rac` and `data-disabled` in order to be able
|
||||||
// to use the `disabled:` selector in classes.
|
// to use the `disabled:` selector in classes.
|
||||||
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
|
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
|
||||||
};
|
}
|
||||||
} else {
|
: {
|
||||||
props = {
|
|
||||||
...otherProps,
|
...otherProps,
|
||||||
|
|
||||||
type: otherProps.type || "button",
|
type: otherProps.type || "button",
|
||||||
isDisabled,
|
isDisabled,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<Component
|
<Component
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
|
import React, { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type DetailedHTMLProps, type FC, type ReactNode, isValidElement } from "react";
|
||||||
import React, { isValidElement } from "react";
|
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
|
||||||
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
|
||||||
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
|
||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const styles = sortCx({
|
export const styles = sortCx({
|
||||||
common: {
|
common: {
|
||||||
root: [
|
root: [
|
||||||
@@ -192,22 +191,16 @@ export const Button = ({
|
|||||||
|
|
||||||
noTextPadding = isLinkType || noTextPadding;
|
noTextPadding = isLinkType || noTextPadding;
|
||||||
|
|
||||||
let props = {};
|
const props = href
|
||||||
|
? {
|
||||||
if (href) {
|
|
||||||
props = {
|
|
||||||
...otherProps,
|
...otherProps,
|
||||||
|
|
||||||
href: disabled ? undefined : href,
|
href: disabled ? undefined : href,
|
||||||
};
|
}
|
||||||
} else {
|
: {
|
||||||
props = {
|
|
||||||
...otherProps,
|
...otherProps,
|
||||||
|
|
||||||
type: otherProps.type || "button",
|
type: otherProps.type || "button",
|
||||||
isPending: loading,
|
isPending: loading,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
|
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps } from "react";
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps } from "react";
|
||||||
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
|
||||||
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
|
||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
|
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const styles = sortCx({
|
export const styles = sortCx({
|
||||||
common: {
|
common: {
|
||||||
root: "group relative inline-flex h-max cursor-pointer items-center justify-center font-semibold whitespace-nowrap outline-focus-ring transition duration-100 ease-linear before:absolute focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:stroke-fg-disabled disabled:text-fg-disabled disabled:*:text-fg-disabled",
|
root: "group relative inline-flex h-max cursor-pointer items-center justify-center font-semibold whitespace-nowrap outline-focus-ring transition duration-100 ease-linear before:absolute focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:stroke-fg-disabled disabled:text-fg-disabled disabled:*:text-fg-disabled",
|
||||||
@@ -96,27 +96,20 @@ export const SocialButton = ({ size = "lg", theme = "brand", social, className,
|
|||||||
|
|
||||||
const Logo = logos[social];
|
const Logo = logos[social];
|
||||||
|
|
||||||
let props = {};
|
const props = href
|
||||||
|
? {
|
||||||
if (href) {
|
|
||||||
props = {
|
|
||||||
...otherProps,
|
...otherProps,
|
||||||
|
|
||||||
href: disabled ? undefined : href,
|
href: disabled ? undefined : href,
|
||||||
|
|
||||||
// Since anchor elements do not support the `disabled` attribute and state,
|
// Since anchor elements do not support the `disabled` attribute and state,
|
||||||
// we need to specify `data-rac` and `data-disabled` in order to be able
|
// we need to specify `data-rac` and `data-disabled` in order to be able
|
||||||
// to use the `disabled:` selector in classes.
|
// to use the `disabled:` selector in classes.
|
||||||
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
|
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
|
||||||
};
|
}
|
||||||
} else {
|
: {
|
||||||
props = {
|
|
||||||
...otherProps,
|
...otherProps,
|
||||||
|
|
||||||
type: otherProps.type || "button",
|
type: otherProps.type || "button",
|
||||||
isDisabled: disabled,
|
isDisabled: disabled,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import type { FC, RefAttributes } from "react";
|
import type { FC, RefAttributes } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
|
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import type {
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
ButtonProps as AriaButtonProps,
|
|
||||||
MenuItemProps as AriaMenuItemProps,
|
|
||||||
MenuProps as AriaMenuProps,
|
|
||||||
PopoverProps as AriaPopoverProps,
|
|
||||||
SeparatorProps as AriaSeparatorProps,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import {
|
import {
|
||||||
Button as AriaButton,
|
Button as AriaButton,
|
||||||
|
type ButtonProps as AriaButtonProps,
|
||||||
Header as AriaHeader,
|
Header as AriaHeader,
|
||||||
Menu as AriaMenu,
|
Menu as AriaMenu,
|
||||||
MenuItem as AriaMenuItem,
|
MenuItem as AriaMenuItem,
|
||||||
|
type MenuItemProps as AriaMenuItemProps,
|
||||||
|
type MenuProps as AriaMenuProps,
|
||||||
MenuSection as AriaMenuSection,
|
MenuSection as AriaMenuSection,
|
||||||
MenuTrigger as AriaMenuTrigger,
|
MenuTrigger as AriaMenuTrigger,
|
||||||
Popover as AriaPopover,
|
Popover as AriaPopover,
|
||||||
|
type PopoverProps as AriaPopoverProps,
|
||||||
Separator as AriaSeparator,
|
Separator as AriaSeparator,
|
||||||
|
type SeparatorProps as AriaSeparatorProps,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
@@ -31,6 +29,7 @@ interface DropdownItemProps extends AriaMenuItemProps {
|
|||||||
icon?: FC<{ className?: string }>;
|
icon?: FC<{ className?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }: DropdownItemProps) => {
|
const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }: DropdownItemProps) => {
|
||||||
if (unstyled) {
|
if (unstyled) {
|
||||||
return <AriaMenuItem id={label} textValue={label} {...props} />;
|
return <AriaMenuItem id={label} textValue={label} {...props} />;
|
||||||
@@ -89,8 +88,9 @@ const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DropdownMenuProps<T extends object> extends AriaMenuProps<T> {}
|
type DropdownMenuProps<T extends object> = AriaMenuProps<T>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
||||||
return (
|
return (
|
||||||
<AriaMenu
|
<AriaMenu
|
||||||
@@ -104,8 +104,9 @@ const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DropdownPopoverProps extends AriaPopoverProps {}
|
type DropdownPopoverProps = AriaPopoverProps;
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const DropdownPopover = (props: DropdownPopoverProps) => {
|
const DropdownPopover = (props: DropdownPopoverProps) => {
|
||||||
return (
|
return (
|
||||||
<AriaPopover
|
<AriaPopover
|
||||||
@@ -127,10 +128,12 @@ const DropdownPopover = (props: DropdownPopoverProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const DropdownSeparator = (props: AriaSeparatorProps) => {
|
const DropdownSeparator = (props: AriaSeparatorProps) => {
|
||||||
return <AriaSeparator {...props} className={cx("my-1 h-px w-full bg-border-secondary", props.className)} />;
|
return <AriaSeparator {...props} className={cx("my-1 h-px w-full bg-border-secondary", props.className)} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const DropdownDotsButton = (props: AriaButtonProps & RefAttributes<HTMLButtonElement>) => {
|
const DropdownDotsButton = (props: AriaButtonProps & RefAttributes<HTMLButtonElement>) => {
|
||||||
return (
|
return (
|
||||||
<AriaButton
|
<AriaButton
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { DetailedReactHTMLElement, HTMLAttributes, ReactNode } from "react";
|
import React, { type DetailedReactHTMLElement, type HTMLAttributes, type ReactNode, cloneElement, useRef } from "react";
|
||||||
import React, { cloneElement, useRef } from "react";
|
|
||||||
import { filterDOMProps } from "@react-aria/utils";
|
import { filterDOMProps } from "@react-aria/utils";
|
||||||
|
|
||||||
interface FileTriggerProps {
|
interface FileTriggerProps {
|
||||||
@@ -42,6 +41,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
|
|||||||
const clonableElement = React.Children.only(children);
|
const clonableElement = React.Children.only(children);
|
||||||
|
|
||||||
// Clone the child element and add an `onClick` handler to open the file dialog.
|
// Clone the child element and add an `onClick` handler to open the file dialog.
|
||||||
|
// eslint-disable-next-line react-hooks/refs
|
||||||
const mainElement = cloneElement(clonableElement as DetailedReactHTMLElement<HTMLAttributes<HTMLElement>, HTMLElement>, {
|
const mainElement = cloneElement(clonableElement as DetailedReactHTMLElement<HTMLAttributes<HTMLElement>, HTMLElement>, {
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (inputRef.current?.value) {
|
if (inputRef.current?.value) {
|
||||||
@@ -63,7 +63,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
|
|||||||
onChange={(e) => onSelect?.(e.target.files)}
|
onChange={(e) => onSelect?.(e.target.files)}
|
||||||
capture={defaultCamera}
|
capture={defaultCamera}
|
||||||
multiple={allowsMultiple}
|
multiple={allowsMultiple}
|
||||||
// @ts-expect-error
|
// @ts-expect-error -- webkitdirectory is not in React's HTML types but is valid in modern browsers
|
||||||
webkitdirectory={acceptDirectory ? "" : undefined}
|
webkitdirectory={acceptDirectory ? "" : undefined}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ReactNode, Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import type { TextProps as AriaTextProps } from "react-aria-components";
|
import { Text as AriaText, type TextProps as AriaTextProps } from "react-aria-components";
|
||||||
import { Text as AriaText } from "react-aria-components";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface HintTextProps extends AriaTextProps {
|
interface HintTextProps extends AriaTextProps {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { type HTMLAttributes, type ReactNode } from "react";
|
import { type HTMLAttributes, type ReactNode } from "react";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import type { InputBaseProps } from "@/components/base/input/input";
|
import { type InputBaseProps, TextField } from "@/components/base/input/input";
|
||||||
import { TextField } from "@/components/base/input/input";
|
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useControlledState } from "@react-stately/utils";
|
import { useControlledState } from "@react-stately/utils";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import type { InputBaseProps } from "@/components/base/input/input";
|
import { InputBase, type InputBaseProps, TextField } from "@/components/base/input/input";
|
||||||
import { InputBase, TextField } from "@/components/base/input/input";
|
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { AmexIcon, DiscoverIcon, MastercardIcon, UnionPayIcon, VisaIcon } from "@/components/foundations/payment-icons";
|
import { AmexIcon, DiscoverIcon, MastercardIcon, UnionPayIcon, VisaIcon } from "@/components/foundations/payment-icons";
|
||||||
|
|
||||||
@@ -62,6 +61,7 @@ const detectCardType = (number: string) => {
|
|||||||
/**
|
/**
|
||||||
* Format the card number in groups of 4 digits (i.e. 1234 5678 9012 3456).
|
* Format the card number in groups of 4 digits (i.e. 1234 5678 9012 3456).
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const formatCardNumber = (number: string) => {
|
export const formatCardNumber = (number: string) => {
|
||||||
// Remove non-numeric characters
|
// Remove non-numeric characters
|
||||||
const cleaned = number.replace(/\D/g, "");
|
const cleaned = number.replace(/\D/g, "");
|
||||||
@@ -76,7 +76,7 @@ export const formatCardNumber = (number: string) => {
|
|||||||
return cleaned;
|
return cleaned;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PaymentInputProps extends Omit<InputBaseProps, "icon"> {}
|
type PaymentInputProps = Omit<InputBaseProps, "icon">;
|
||||||
|
|
||||||
export const PaymentInput = ({ onChange, value, defaultValue, className, maxLength = 19, label, hint, ...props }: PaymentInputProps) => {
|
export const PaymentInput = ({ onChange, value, defaultValue, className, maxLength = 19, label, hint, ...props }: PaymentInputProps) => {
|
||||||
const [cardNumber, setCardNumber] = useControlledState(value, defaultValue || "", (value) => {
|
const [cardNumber, setCardNumber] = useControlledState(value, defaultValue || "", (value) => {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext } from "react";
|
import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext } from "react";
|
||||||
|
import { faCircleExclamation, faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faCircleQuestion, faCircleExclamation } from "@fortawesome/pro-duotone-svg-icons";
|
import {
|
||||||
import type { InputProps as AriaInputProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
|
Group as AriaGroup,
|
||||||
import { Group as AriaGroup, Input as AriaInput, TextField as AriaTextField } from "react-aria-components";
|
Input as AriaInput,
|
||||||
|
type InputProps as AriaInputProps,
|
||||||
|
TextField as AriaTextField,
|
||||||
|
type TextFieldProps as AriaTextFieldProps,
|
||||||
|
} from "react-aria-components";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
@@ -192,9 +197,7 @@ interface BaseProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TextFieldProps
|
interface TextFieldProps
|
||||||
extends BaseProps,
|
extends BaseProps, AriaTextFieldProps, Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName"> {
|
||||||
AriaTextFieldProps,
|
|
||||||
Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName"> {
|
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { ReactNode, Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
|
import { faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import type { LabelProps as AriaLabelProps } from "react-aria-components";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Label as AriaLabel } from "react-aria-components";
|
import { Label as AriaLabel, type LabelProps as AriaLabelProps } from "react-aria-components";
|
||||||
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ComponentPropsWithRef } from "react";
|
import { type ComponentPropsWithRef, createContext, useContext, useId } from "react";
|
||||||
import { createContext, useContext, useId } from "react";
|
|
||||||
import { OTPInput, OTPInputContext } from "input-otp";
|
import { OTPInput, OTPInputContext } from "input-otp";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
@@ -15,6 +14,7 @@ const PinInputContext = createContext<PinInputContextType>({
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const usePinInputContext = () => {
|
export const usePinInputContext = () => {
|
||||||
const context = useContext(PinInputContext);
|
const context = useContext(PinInputContext);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import type { FocusEventHandler, PointerEventHandler, RefAttributes, RefObject } from "react";
|
import { type FocusEventHandler, type PointerEventHandler, type RefAttributes, type RefObject, useCallback, useContext, useRef, useState } from "react";
|
||||||
import { useCallback, useContext, useRef, useState } from "react";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps } from "react-aria-components";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
|
import {
|
||||||
|
ComboBox as AriaComboBox,
|
||||||
|
type ComboBoxProps as AriaComboBoxProps,
|
||||||
|
Group as AriaGroup,
|
||||||
|
type GroupProps as AriaGroupProps,
|
||||||
|
Input as AriaInput,
|
||||||
|
ListBox as AriaListBox,
|
||||||
|
type ListBoxProps as AriaListBoxProps,
|
||||||
|
ComboBoxStateContext,
|
||||||
|
} from "react-aria-components";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { Popover } from "@/components/base/select/popover";
|
import { Popover } from "@/components/base/select/popover";
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
import type { FC, FocusEventHandler, KeyboardEvent, PointerEventHandler, RefAttributes, RefObject } from "react";
|
import {
|
||||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
type FC,
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
type FocusEventHandler,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type PointerEventHandler,
|
||||||
|
type RefAttributes,
|
||||||
|
type RefObject,
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
const SearchIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
|
||||||
import { FocusScope, useFilter, useFocusManager } from "react-aria";
|
import { FocusScope, useFilter, useFocusManager } from "react-aria";
|
||||||
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps, Key } from "react-aria-components";
|
import {
|
||||||
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
|
ComboBox as AriaComboBox,
|
||||||
import type { ListData } from "react-stately";
|
type ComboBoxProps as AriaComboBoxProps,
|
||||||
import { useListData } from "react-stately";
|
Group as AriaGroup,
|
||||||
|
type GroupProps as AriaGroupProps,
|
||||||
|
Input as AriaInput,
|
||||||
|
ListBox as AriaListBox,
|
||||||
|
type ListBoxProps as AriaListBoxProps,
|
||||||
|
ComboBoxStateContext,
|
||||||
|
type Key,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { type ListData, useListData } from "react-stately";
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import type { IconComponentType } from "@/components/base/badges/badge-types";
|
import type { IconComponentType } from "@/components/base/badges/badge-types";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
@@ -20,6 +37,8 @@ import { useResizeObserver } from "@/hooks/use-resize-observer";
|
|||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { SelectItem } from "./select-item";
|
import { SelectItem } from "./select-item";
|
||||||
|
|
||||||
|
const SearchIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
|
|
||||||
interface ComboBoxValueProps extends AriaGroupProps {
|
interface ComboBoxValueProps extends AriaGroupProps {
|
||||||
size: "sm" | "md";
|
size: "sm" | "md";
|
||||||
shortcut?: boolean;
|
shortcut?: boolean;
|
||||||
@@ -133,7 +152,7 @@ export const MultiSelectBase = ({
|
|||||||
// Resize observer for popover width
|
// Resize observer for popover width
|
||||||
const onResize = useCallback(() => {
|
const onResize = useCallback(() => {
|
||||||
if (!placeholderRef.current) return;
|
if (!placeholderRef.current) return;
|
||||||
let divRect = placeholderRef.current?.getBoundingClientRect();
|
const divRect = placeholderRef.current?.getBoundingClientRect();
|
||||||
setPopoverWidth(divRect.width + "px");
|
setPopoverWidth(divRect.width + "px");
|
||||||
}, [placeholderRef, setPopoverWidth]);
|
}, [placeholderRef, setPopoverWidth]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { RefAttributes } from "react";
|
import type { RefAttributes } from "react";
|
||||||
import type { PopoverProps as AriaPopoverProps } from "react-aria-components";
|
import { Popover as AriaPopover, type PopoverProps as AriaPopoverProps } from "react-aria-components";
|
||||||
import { Popover as AriaPopover } from "react-aria-components";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface PopoverProps extends AriaPopoverProps, RefAttributes<HTMLElement> {
|
interface PopoverProps extends AriaPopoverProps, RefAttributes<HTMLElement> {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { isValidElement, useContext } from "react";
|
import { isValidElement, useContext } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faCheck } from "@fortawesome/pro-duotone-svg-icons";
|
import { faCheck } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import type { ListBoxItemProps as AriaListBoxItemProps } from "react-aria-components";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { ListBoxItem as AriaListBoxItem, Text as AriaText } from "react-aria-components";
|
import { ListBoxItem as AriaListBoxItem, type ListBoxItemProps as AriaListBoxItemProps, Text as AriaText } from "react-aria-components";
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
import type { SelectItemType } from "./select";
|
import { SelectContext, type SelectItemType } from "./select";
|
||||||
import { SelectContext } from "./select";
|
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: "p-2 pr-2.5",
|
sm: "p-2 pr-2.5",
|
||||||
@@ -83,11 +81,7 @@ export const SelectItem = ({ label, id, value, avatarUrl, supportingText, isDisa
|
|||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faCheck}
|
icon={faCheck}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cx(
|
className={cx("ml-auto text-fg-brand-primary", size === "sm" ? "size-4" : "size-5", state.isDisabled && "text-fg-disabled")}
|
||||||
"ml-auto text-fg-brand-primary",
|
|
||||||
size === "sm" ? "size-4" : "size-5",
|
|
||||||
state.isDisabled && "text-fg-disabled",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type SelectHTMLAttributes, useId } from "react";
|
import { type SelectHTMLAttributes, useId } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import type { FC, ReactNode, Ref, RefAttributes } from "react";
|
import { type FC, type ReactNode, type Ref, type RefAttributes, createContext, isValidElement } from "react";
|
||||||
import { createContext, isValidElement } from "react";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import type { SelectProps as AriaSelectProps } from "react-aria-components";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Button as AriaButton, ListBox as AriaListBox, Select as AriaSelect, SelectValue as AriaSelectValue } from "react-aria-components";
|
import {
|
||||||
|
Button as AriaButton,
|
||||||
|
ListBox as AriaListBox,
|
||||||
|
Select as AriaSelect,
|
||||||
|
type SelectProps as AriaSelectProps,
|
||||||
|
SelectValue as AriaSelectValue,
|
||||||
|
} from "react-aria-components";
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
@@ -47,6 +51,7 @@ interface SelectValueProps {
|
|||||||
placeholderIcon?: FC | ReactNode;
|
placeholderIcon?: FC | ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const sizes = {
|
export const sizes = {
|
||||||
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
|
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
|
||||||
md: { root: "py-2.5 px-3.5", shortcut: "pr-3" },
|
md: { root: "py-2.5 px-3.5", shortcut: "pr-3" },
|
||||||
@@ -106,6 +111,7 @@ const SelectValue = ({ isOpen, isFocused, isDisabled, size, placeholder, placeho
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const SelectContext = createContext<{ size: "sm" | "md" }>({ size: "sm" });
|
export const SelectContext = createContext<{ size: "sm" | "md" }>({ size: "sm" });
|
||||||
|
|
||||||
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {
|
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { SliderProps as AriaSliderProps } from "react-aria-components";
|
|
||||||
import {
|
import {
|
||||||
Label as AriaLabel,
|
Label as AriaLabel,
|
||||||
Slider as AriaSlider,
|
Slider as AriaSlider,
|
||||||
SliderOutput as AriaSliderOutput,
|
SliderOutput as AriaSliderOutput,
|
||||||
|
type SliderProps as AriaSliderProps,
|
||||||
SliderThumb as AriaSliderThumb,
|
SliderThumb as AriaSliderThumb,
|
||||||
SliderTrack as AriaSliderTrack,
|
SliderTrack as AriaSliderTrack,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { RefAttributes } from "react";
|
import type { RefAttributes } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
|
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { ReactNode, Ref } from "react";
|
import React, { type ReactNode, type Ref } from "react";
|
||||||
import React from "react";
|
import {
|
||||||
import type { TextAreaProps as AriaTextAreaProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
|
TextArea as AriaTextArea,
|
||||||
import { TextArea as AriaTextArea, TextField as AriaTextField } from "react-aria-components";
|
type TextAreaProps as AriaTextAreaProps,
|
||||||
|
TextField as AriaTextField,
|
||||||
|
type TextFieldProps as AriaTextFieldProps,
|
||||||
|
} from "react-aria-components";
|
||||||
import { HintText } from "@/components/base/input/hint-text";
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
import { Label } from "@/components/base/input/label";
|
import { Label } from "@/components/base/input/label";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { SwitchProps as AriaSwitchProps } from "react-aria-components";
|
import { Switch as AriaSwitch, type SwitchProps as AriaSwitchProps } from "react-aria-components";
|
||||||
import { Switch as AriaSwitch } from "react-aria-components";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface ToggleBaseProps {
|
interface ToggleBaseProps {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type {
|
import {
|
||||||
ButtonProps as AriaButtonProps,
|
Button as AriaButton,
|
||||||
TooltipProps as AriaTooltipProps,
|
type ButtonProps as AriaButtonProps,
|
||||||
TooltipTriggerComponentProps as AriaTooltipTriggerComponentProps,
|
OverlayArrow as AriaOverlayArrow,
|
||||||
|
Tooltip as AriaTooltip,
|
||||||
|
type TooltipProps as AriaTooltipProps,
|
||||||
|
TooltipTrigger as AriaTooltipTrigger,
|
||||||
|
type TooltipTriggerComponentProps as AriaTooltipTriggerComponentProps,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
import { Button as AriaButton, OverlayArrow as AriaOverlayArrow, Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "react-aria-components";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface TooltipProps extends AriaTooltipTriggerComponentProps, Omit<AriaTooltipProps, "children"> {
|
interface TooltipProps extends AriaTooltipTriggerComponentProps, Omit<AriaTooltipProps, "children"> {
|
||||||
@@ -96,7 +99,7 @@ export const Tooltip = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TooltipTriggerProps extends AriaButtonProps {}
|
type TooltipTriggerProps = AriaButtonProps;
|
||||||
|
|
||||||
export const TooltipTrigger = ({ children, className, ...buttonProps }: TooltipTriggerProps) => {
|
export const TooltipTrigger = ({ children, className, ...buttonProps }: TooltipTriggerProps) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from "react";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { formatShortDate } from "@/lib/format";
|
||||||
|
import type { Call, CallDisposition } from "@/types/entities";
|
||||||
|
|
||||||
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
||||||
import { formatShortDate } from '@/lib/format';
|
|
||||||
import type { Call, CallDisposition } from '@/types/entities';
|
|
||||||
|
|
||||||
interface CallLogProps {
|
interface CallLogProps {
|
||||||
calls: Call[];
|
calls: Call[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
const dispositionConfig: Record<CallDisposition, { label: string; color: "success" | "brand" | "blue-light" | "warning" | "gray" | "error" }> = {
|
||||||
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
|
APPOINTMENT_BOOKED: { label: "Booked", color: "success" },
|
||||||
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
FOLLOW_UP_SCHEDULED: { label: "Follow-up", color: "brand" },
|
||||||
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
|
INFO_PROVIDED: { label: "Info", color: "blue-light" },
|
||||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
NO_ANSWER: { label: "No Answer", color: "warning" },
|
||||||
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
|
WRONG_NUMBER: { label: "Wrong #", color: "gray" },
|
||||||
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' },
|
CALLBACK_REQUESTED: { label: "Not Interested", color: "error" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (seconds: number | null): string => {
|
const formatDuration = (seconds: number | null): string => {
|
||||||
if (seconds === null || seconds === 0) return '0 min';
|
if (seconds === null || seconds === 0) return "0 min";
|
||||||
const minutes = Math.round(seconds / 60);
|
const minutes = Math.round(seconds / 60);
|
||||||
return `${minutes} min`;
|
return `${minutes} min`;
|
||||||
};
|
};
|
||||||
@@ -33,34 +33,29 @@ export const CallLog = ({ calls }: CallLogProps) => {
|
|||||||
<div className="flex items-center justify-between border-b border-secondary px-5 py-4">
|
<div className="flex items-center justify-between border-b border-secondary px-5 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-bold text-primary">Today's Calls</span>
|
<span className="text-sm font-bold text-primary">Today's Calls</span>
|
||||||
<Badge size="sm" color="gray">{calls.length}</Badge>
|
<Badge size="sm" color="gray">
|
||||||
|
{calls.length}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{calls.length > 0 ? (
|
{calls.length > 0 ? (
|
||||||
<div className="divide-y divide-secondary">
|
<div className="divide-y divide-secondary">
|
||||||
{calls.map((call) => {
|
{calls.map((call) => {
|
||||||
const config = call.disposition !== null
|
const config = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
||||||
? dispositionConfig[call.disposition]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={call.id} className="flex items-center gap-3 px-5 py-3">
|
||||||
key={call.id}
|
<span className="w-20 shrink-0 text-xs text-quaternary">{call.startedAt !== null ? formatShortDate(call.startedAt) : "—"}</span>
|
||||||
className="flex items-center gap-3 px-5 py-3"
|
|
||||||
>
|
|
||||||
<span className="w-20 shrink-0 text-xs text-quaternary">
|
|
||||||
{call.startedAt !== null ? formatShortDate(call.startedAt) : '—'}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-primary">
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-primary">
|
||||||
{call.leadName ?? call.callerNumber?.[0]?.number ?? 'Unknown'}
|
{call.leadName ?? call.callerNumber?.[0]?.number ?? "Unknown"}
|
||||||
</span>
|
</span>
|
||||||
{config !== null && (
|
{config !== null && (
|
||||||
<Badge size="sm" color={config.color}>{config.label}</Badge>
|
<Badge size="sm" color={config.color}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span className="w-12 shrink-0 text-right text-xs text-quaternary">
|
<span className="w-12 shrink-0 text-right text-xs text-quaternary">{formatDuration(call.durationSeconds)}</span>
|
||||||
{formatDuration(call.durationSeconds)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { faSparkles, faUserPlus } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { faSparkles, faUserPlus } from '@fortawesome/pro-duotone-svg-icons';
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { formatShortDate } from '@/lib/format';
|
import { formatShortDate } from "@/lib/format";
|
||||||
import type { Lead, LeadActivity } from '@/types/entities';
|
import type { Lead, LeadActivity } from "@/types/entities";
|
||||||
|
|
||||||
interface CallPrepCardProps {
|
interface CallPrepCardProps {
|
||||||
lead: Lead | null;
|
lead: Lead | null;
|
||||||
@@ -19,8 +19,8 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
|
|||||||
const leadActivities = activities
|
const leadActivities = activities
|
||||||
.filter((a) => a.leadId === lead.id)
|
.filter((a) => a.leadId === lead.id)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const dateA = a.occurredAt ?? a.createdAt ?? '';
|
const dateA = a.occurredAt ?? a.createdAt ?? "";
|
||||||
const dateB = b.occurredAt ?? b.createdAt ?? '';
|
const dateB = b.occurredAt ?? b.createdAt ?? "";
|
||||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||||
})
|
})
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
@@ -29,22 +29,16 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
|
|||||||
<div className="rounded-xl bg-brand-primary p-4">
|
<div className="rounded-xl bg-brand-primary p-4">
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Call Prep</span>
|
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Call Prep</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lead.aiSummary && (
|
{lead.aiSummary && <p className="text-sm text-primary">{lead.aiSummary}</p>}
|
||||||
<p className="text-sm text-primary">{lead.aiSummary}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lead.aiSuggestedAction && (
|
{lead.aiSuggestedAction && (
|
||||||
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1.5 text-xs font-semibold text-white">
|
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1.5 text-xs font-semibold text-white">{lead.aiSuggestedAction}</span>
|
||||||
{lead.aiSuggestedAction}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!lead.aiSummary && !lead.aiSuggestedAction && (
|
{!lead.aiSummary && !lead.aiSuggestedAction && <p className="text-sm text-quaternary">No AI insights available for this lead.</p>}
|
||||||
<p className="text-sm text-quaternary">No AI insights available for this lead.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{leadActivities.length > 0 && (
|
{leadActivities.length > 0 && (
|
||||||
<div className="mt-3 border-t border-brand pt-3">
|
<div className="mt-3 border-t border-brand pt-3">
|
||||||
@@ -52,11 +46,11 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
|
|||||||
<div className="mt-1.5 space-y-1">
|
<div className="mt-1.5 space-y-1">
|
||||||
{leadActivities.map((a) => (
|
{leadActivities.map((a) => (
|
||||||
<div key={a.id} className="flex items-start gap-2">
|
<div key={a.id} className="flex items-start gap-2">
|
||||||
<Badge size="sm" color="gray" className="shrink-0 mt-0.5">{a.activityType}</Badge>
|
<Badge size="sm" color="gray" className="mt-0.5 shrink-0">
|
||||||
|
{a.activityType}
|
||||||
|
</Badge>
|
||||||
<span className="flex-1 text-xs text-secondary">{a.summary}</span>
|
<span className="flex-1 text-xs text-secondary">{a.summary}</span>
|
||||||
{a.occurredAt && (
|
{a.occurredAt && <span className="shrink-0 text-xs text-quaternary">{formatShortDate(a.occurredAt)}</span>}
|
||||||
<span className="shrink-0 text-xs text-quaternary">{formatShortDate(a.occurredAt)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -70,10 +64,10 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
|
|||||||
<div className="rounded-xl bg-secondary p-4">
|
<div className="rounded-xl bg-secondary p-4">
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-quaternary" />
|
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-quaternary" />
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">Unknown Caller</span>
|
<span className="text-xs font-bold tracking-wider text-tertiary uppercase">Unknown Caller</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-secondary">
|
<p className="text-sm text-secondary">
|
||||||
No record found for <span className="font-semibold">{callerPhone || 'this number'}</span>
|
No record found for <span className="font-semibold">{callerPhone || "this number"}</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 space-y-1.5">
|
<div className="mt-3 space-y-1.5">
|
||||||
<p className="text-xs font-semibold text-secondary">Suggested script:</p>
|
<p className="text-xs font-semibold text-secondary">Suggested script:</p>
|
||||||
@@ -85,9 +79,11 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Button size="sm" color="secondary" iconLeading={({ className }: { className?: string }) => (
|
<Button
|
||||||
<FontAwesomeIcon icon={faUserPlus} className={className} />
|
size="sm"
|
||||||
)}>
|
color="secondary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faUserPlus} className={className} />}
|
||||||
|
>
|
||||||
Create Lead
|
Create Lead
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { faPhoneArrowUpRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { faPhoneArrowUpRight } from '@fortawesome/pro-duotone-svg-icons';
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface CallSimulatorProps {
|
interface CallSimulatorProps {
|
||||||
onSimulate: () => void;
|
onSimulate: () => void;
|
||||||
@@ -14,14 +14,14 @@ export const CallSimulator = ({ onSimulate, isCallActive }: CallSimulatorProps)
|
|||||||
onClick={onSimulate}
|
onClick={onSimulate}
|
||||||
disabled={isCallActive}
|
disabled={isCallActive}
|
||||||
className={cx(
|
className={cx(
|
||||||
'inline-flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold text-white transition duration-100 ease-linear sm:w-auto',
|
"inline-flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold text-white transition duration-100 ease-linear sm:w-auto",
|
||||||
isCallActive
|
isCallActive
|
||||||
? 'cursor-not-allowed bg-disabled text-disabled'
|
? "cursor-not-allowed bg-disabled text-disabled"
|
||||||
: 'cursor-pointer bg-brand-solid hover:bg-brand-solid_hover [&:hover_svg]:animate-[ring-shake_0.5s_ease-in-out]',
|
: "cursor-pointer bg-brand-solid hover:bg-brand-solid_hover [&:hover_svg]:animate-[ring-shake_0.5s_ease-in-out]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPhoneArrowUpRight} className="size-5 shrink-0" />
|
<FontAwesomeIcon icon={faPhoneArrowUpRight} className="size-5 shrink-0" />
|
||||||
{isCallActive ? 'Call in progress...' : 'Simulate Incoming Call'}
|
{isCallActive ? "Call in progress..." : "Simulate Incoming Call"}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import type { FC } from 'react';
|
import { type FC, useState } from "react";
|
||||||
import { useState } from 'react';
|
import { faPhone } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faPhone } from '@fortawesome/pro-duotone-svg-icons';
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { notify } from "@/lib/toast";
|
||||||
|
import { useSip } from "@/providers/sip-provider";
|
||||||
|
|
||||||
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => <FontAwesomeIcon icon={faPhone} className={className} {...rest} />;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
import { Button } from '@/components/base/buttons/button';
|
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => (
|
||||||
import { useSip } from '@/providers/sip-provider';
|
<FontAwesomeIcon icon={faPhone} className={className} {...rest} />
|
||||||
import { notify } from '@/lib/toast';
|
);
|
||||||
|
|
||||||
interface ClickToCallButtonProps {
|
interface ClickToCallButtonProps {
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
size?: 'sm' | 'md';
|
size?: "sm" | "md";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => {
|
export const ClickToCallButton = ({ phoneNumber, label, size = "sm" }: ClickToCallButtonProps) => {
|
||||||
const { isRegistered, isInCall, dialOutbound } = useSip();
|
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||||
const [dialing, setDialing] = useState(false);
|
const [dialing, setDialing] = useState(false);
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCa
|
|||||||
try {
|
try {
|
||||||
await dialOutbound(phoneNumber);
|
await dialOutbound(phoneNumber);
|
||||||
} catch {
|
} catch {
|
||||||
notify.error('Dial Failed', 'Could not place the call');
|
notify.error("Dial Failed", "Could not place the call");
|
||||||
} finally {
|
} finally {
|
||||||
setDialing(false);
|
setDialing(false);
|
||||||
}
|
}
|
||||||
@@ -39,7 +41,7 @@ export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCa
|
|||||||
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
|
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
|
||||||
isLoading={dialing}
|
isLoading={dialing}
|
||||||
>
|
>
|
||||||
{label ?? 'Call'}
|
{label ?? "Call"}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Call } from '@/types/entities';
|
import type { Call } from "@/types/entities";
|
||||||
|
|
||||||
interface DailyStatsProps {
|
interface DailyStatsProps {
|
||||||
calls: Call[];
|
calls: Call[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAvgDuration = (calls: Call[]): string => {
|
const formatAvgDuration = (calls: Call[]): string => {
|
||||||
if (calls.length === 0) return '0.0 min';
|
if (calls.length === 0) return "0.0 min";
|
||||||
const totalSeconds = calls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
const totalSeconds = calls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
||||||
const avgMinutes = totalSeconds / calls.length / 60;
|
const avgMinutes = totalSeconds / calls.length / 60;
|
||||||
return `${avgMinutes.toFixed(1)} min`;
|
return `${avgMinutes.toFixed(1)} min`;
|
||||||
@@ -13,29 +13,24 @@ const formatAvgDuration = (calls: Call[]): string => {
|
|||||||
|
|
||||||
export const DailyStats = ({ calls }: DailyStatsProps) => {
|
export const DailyStats = ({ calls }: DailyStatsProps) => {
|
||||||
const callsHandled = calls.length;
|
const callsHandled = calls.length;
|
||||||
const appointmentsBooked = calls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
const appointmentsBooked = calls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length;
|
||||||
const followUps = calls.filter((c) => c.disposition === 'FOLLOW_UP_SCHEDULED').length;
|
const followUps = calls.filter((c) => c.disposition === "FOLLOW_UP_SCHEDULED").length;
|
||||||
const avgDuration = formatAvgDuration(calls);
|
const avgDuration = formatAvgDuration(calls);
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: 'Calls Handled', value: String(callsHandled) },
|
{ label: "Calls Handled", value: String(callsHandled) },
|
||||||
{ label: 'Appointments Booked', value: String(appointmentsBooked) },
|
{ label: "Appointments Booked", value: String(appointmentsBooked) },
|
||||||
{ label: 'Follow-ups', value: String(followUps) },
|
{ label: "Follow-ups", value: String(followUps) },
|
||||||
{ label: 'Avg Duration', value: avgDuration },
|
{ label: "Avg Duration", value: avgDuration },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h3 className="text-sm font-bold text-primary">Daily Stats</h3>
|
<h3 className="text-sm font-bold text-primary">Daily Stats</h3>
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div
|
<div key={stat.label} className="rounded-xl bg-secondary p-4 text-center">
|
||||||
key={stat.label}
|
|
||||||
className="rounded-xl bg-secondary p-4 text-center"
|
|
||||||
>
|
|
||||||
<div className="text-display-xs font-bold text-primary">{stat.value}</div>
|
<div className="text-display-xs font-bold text-primary">{stat.value}</div>
|
||||||
<div className="mt-1 text-xs uppercase tracking-wider text-tertiary">
|
<div className="mt-1 text-xs tracking-wider text-tertiary uppercase">{stat.label}</div>
|
||||||
{stat.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { TextArea } from '@/components/base/textarea/textarea';
|
import { TextArea } from "@/components/base/textarea/textarea";
|
||||||
import type { CallDisposition } from '@/types/entities';
|
import type { CallDisposition } from "@/types/entities";
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface DispositionFormProps {
|
interface DispositionFormProps {
|
||||||
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
||||||
@@ -15,46 +15,46 @@ const dispositionOptions: Array<{
|
|||||||
defaultClass: string;
|
defaultClass: string;
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
value: 'APPOINTMENT_BOOKED',
|
value: "APPOINTMENT_BOOKED",
|
||||||
label: 'Appointment Booked',
|
label: "Appointment Booked",
|
||||||
activeClass: 'bg-success-solid text-white ring-transparent',
|
activeClass: "bg-success-solid text-white ring-transparent",
|
||||||
defaultClass: 'bg-success-primary text-success-primary border-success',
|
defaultClass: "bg-success-primary text-success-primary border-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'FOLLOW_UP_SCHEDULED',
|
value: "FOLLOW_UP_SCHEDULED",
|
||||||
label: 'Follow-up Needed',
|
label: "Follow-up Needed",
|
||||||
activeClass: 'bg-brand-solid text-white ring-transparent',
|
activeClass: "bg-brand-solid text-white ring-transparent",
|
||||||
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
|
defaultClass: "bg-brand-primary text-brand-secondary border-brand",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'INFO_PROVIDED',
|
value: "INFO_PROVIDED",
|
||||||
label: 'Info Provided',
|
label: "Info Provided",
|
||||||
activeClass: 'bg-utility-blue-light-600 text-white ring-transparent',
|
activeClass: "bg-utility-blue-light-600 text-white ring-transparent",
|
||||||
defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200',
|
defaultClass: "bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'NO_ANSWER',
|
value: "NO_ANSWER",
|
||||||
label: 'No Answer',
|
label: "No Answer",
|
||||||
activeClass: 'bg-warning-solid text-white ring-transparent',
|
activeClass: "bg-warning-solid text-white ring-transparent",
|
||||||
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
defaultClass: "bg-warning-primary text-warning-primary border-warning",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'WRONG_NUMBER',
|
value: "WRONG_NUMBER",
|
||||||
label: 'Wrong Number',
|
label: "Wrong Number",
|
||||||
activeClass: 'bg-secondary-solid text-white ring-transparent',
|
activeClass: "bg-secondary-solid text-white ring-transparent",
|
||||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
defaultClass: "bg-secondary text-secondary border-secondary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'CALLBACK_REQUESTED',
|
value: "CALLBACK_REQUESTED",
|
||||||
label: 'Not Interested',
|
label: "Not Interested",
|
||||||
activeClass: 'bg-error-solid text-white ring-transparent',
|
activeClass: "bg-error-solid text-white ring-transparent",
|
||||||
defaultClass: 'bg-error-primary text-error-primary border-error',
|
defaultClass: "bg-error-primary text-error-primary border-error",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
|
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
|
||||||
const [selected, setSelected] = useState<CallDisposition | null>(defaultDisposition ?? null);
|
const [selected, setSelected] = useState<CallDisposition | null>(defaultDisposition ?? null);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (selected === null) return;
|
if (selected === null) return;
|
||||||
@@ -74,10 +74,8 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelected(option.value)}
|
onClick={() => setSelected(option.value)}
|
||||||
className={cx(
|
className={cx(
|
||||||
'cursor-pointer rounded-xl border-2 p-3 text-xs font-semibold transition duration-100 ease-linear',
|
"cursor-pointer rounded-xl border-2 p-3 text-xs font-semibold transition duration-100 ease-linear",
|
||||||
isSelected
|
isSelected ? cx(option.activeClass, "ring-2 ring-brand") : option.defaultClass,
|
||||||
? cx(option.activeClass, 'ring-2 ring-brand')
|
|
||||||
: option.defaultClass,
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -86,13 +84,7 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextArea
|
<TextArea label="Notes (optional)" placeholder="Add any notes about this call..." value={notes} onChange={(value) => setNotes(value)} rows={3} />
|
||||||
label="Notes (optional)"
|
|
||||||
placeholder="Add any notes about this call..."
|
|
||||||
value={notes}
|
|
||||||
onChange={(value) => setNotes(value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
@@ -100,10 +92,10 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={selected === null}
|
disabled={selected === null}
|
||||||
className={cx(
|
className={cx(
|
||||||
'rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear',
|
"rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear",
|
||||||
selected !== null
|
selected !== null
|
||||||
? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover'
|
? "cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover"
|
||||||
: 'cursor-not-allowed bg-disabled text-disabled',
|
: "cursor-not-allowed bg-disabled text-disabled",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Save & Close Call
|
Save & Close Call
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { faCircleCheck, faClock, faEnvelope, faPhone, faPhoneArrowDown, faStars } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { faPhone, faPhoneArrowDown, faCircleCheck, faEnvelope, faClock, faStars } from '@fortawesome/pro-duotone-svg-icons';
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Avatar } from '@/components/base/avatar/avatar';
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
import { SourceTag } from '@/components/shared/source-tag';
|
import { AgeIndicator } from "@/components/shared/age-indicator";
|
||||||
import { AgeIndicator } from '@/components/shared/age-indicator';
|
import { SourceTag } from "@/components/shared/source-tag";
|
||||||
import { DispositionForm } from './disposition-form';
|
import { formatPhone, formatShortDate, getInitials } from "@/lib/format";
|
||||||
import { formatPhone, formatShortDate, getInitials } from '@/lib/format';
|
import type { CallDisposition, Campaign, Lead, LeadActivity } from "@/types/entities";
|
||||||
import type { Lead, LeadActivity, CallDisposition, Campaign } from '@/types/entities';
|
import { DispositionForm } from "./disposition-form";
|
||||||
|
|
||||||
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
|
type CallState = "idle" | "ringing" | "active" | "completed";
|
||||||
|
|
||||||
interface IncomingCallCardProps {
|
interface IncomingCallCardProps {
|
||||||
callState: CallState;
|
callState: CallState;
|
||||||
@@ -21,57 +21,57 @@ interface IncomingCallCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activityTypeIcons: Record<string, string> = {
|
const activityTypeIcons: Record<string, string> = {
|
||||||
CALL_MADE: 'phone',
|
CALL_MADE: "phone",
|
||||||
CALL_RECEIVED: 'phone',
|
CALL_RECEIVED: "phone",
|
||||||
WHATSAPP_SENT: 'message',
|
WHATSAPP_SENT: "message",
|
||||||
WHATSAPP_RECEIVED: 'message',
|
WHATSAPP_RECEIVED: "message",
|
||||||
SMS_SENT: 'message',
|
SMS_SENT: "message",
|
||||||
EMAIL_SENT: 'email',
|
EMAIL_SENT: "email",
|
||||||
EMAIL_RECEIVED: 'email',
|
EMAIL_RECEIVED: "email",
|
||||||
NOTE_ADDED: 'note',
|
NOTE_ADDED: "note",
|
||||||
ASSIGNED: 'assign',
|
ASSIGNED: "assign",
|
||||||
STATUS_CHANGE: 'status',
|
STATUS_CHANGE: "status",
|
||||||
APPOINTMENT_BOOKED: 'calendar',
|
APPOINTMENT_BOOKED: "calendar",
|
||||||
FOLLOW_UP_CREATED: 'clock',
|
FOLLOW_UP_CREATED: "clock",
|
||||||
CONVERTED: 'check',
|
CONVERTED: "check",
|
||||||
MARKED_SPAM: 'alert',
|
MARKED_SPAM: "alert",
|
||||||
DUPLICATE_DETECTED: 'alert',
|
DUPLICATE_DETECTED: "alert",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActivityIcon = ({ type }: { type: string }) => {
|
const ActivityIcon = ({ type }: { type: string }) => {
|
||||||
const iconType = activityTypeIcons[type] ?? 'note';
|
const iconType = activityTypeIcons[type] ?? "note";
|
||||||
const baseClass = 'size-3.5 shrink-0 text-fg-quaternary';
|
const baseClass = "size-3.5 shrink-0 text-fg-quaternary";
|
||||||
|
|
||||||
if (iconType === 'phone') return <FontAwesomeIcon icon={faPhone} className={baseClass} />;
|
if (iconType === "phone") return <FontAwesomeIcon icon={faPhone} className={baseClass} />;
|
||||||
if (iconType === 'email') return <FontAwesomeIcon icon={faEnvelope} className={baseClass} />;
|
if (iconType === "email") return <FontAwesomeIcon icon={faEnvelope} className={baseClass} />;
|
||||||
if (iconType === 'clock') return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
if (iconType === "clock") return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
||||||
if (iconType === 'check') return <FontAwesomeIcon icon={faCircleCheck} className={baseClass} />;
|
if (iconType === "check") return <FontAwesomeIcon icon={faCircleCheck} className={baseClass} />;
|
||||||
return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dispositionLabels: Record<CallDisposition, string> = {
|
const dispositionLabels: Record<CallDisposition, string> = {
|
||||||
APPOINTMENT_BOOKED: 'Appointment Booked',
|
APPOINTMENT_BOOKED: "Appointment Booked",
|
||||||
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
|
FOLLOW_UP_SCHEDULED: "Follow-up Needed",
|
||||||
INFO_PROVIDED: 'Info Provided',
|
INFO_PROVIDED: "Info Provided",
|
||||||
NO_ANSWER: 'No Answer',
|
NO_ANSWER: "No Answer",
|
||||||
WRONG_NUMBER: 'Wrong Number',
|
WRONG_NUMBER: "Wrong Number",
|
||||||
CALLBACK_REQUESTED: 'Not Interested',
|
CALLBACK_REQUESTED: "Not Interested",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
||||||
if (callState === 'idle') {
|
if (callState === "idle") {
|
||||||
return <IdleState />;
|
return <IdleState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callState === 'ringing') {
|
if (callState === "ringing") {
|
||||||
return <RingingState lead={lead} />;
|
return <RingingState lead={lead} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callState === 'active' && lead !== null) {
|
if (callState === "active" && lead !== null) {
|
||||||
return <ActiveState lead={lead} activities={activities} campaigns={campaigns} onDisposition={onDisposition} />;
|
return <ActiveState lead={lead} activities={activities} campaigns={campaigns} onDisposition={onDisposition} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callState === 'completed') {
|
if (callState === "completed") {
|
||||||
return <CompletedState disposition={completedDisposition ?? null} />;
|
return <CompletedState disposition={completedDisposition ?? null} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +88,7 @@ const IdleState = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const RingingState = ({ lead }: { lead: Lead | null }) => {
|
const RingingState = ({ lead }: { lead: Lead | null }) => {
|
||||||
const phoneDisplay = lead?.contactPhone?.[0]
|
const phoneDisplay = lead?.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "+91 98765 43210";
|
||||||
? formatPhone(lead.contactPhone[0])
|
|
||||||
: '+91 98765 43210';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-2xl bg-brand-primary p-12 text-center">
|
<div className="flex flex-col items-center justify-center rounded-2xl bg-brand-primary p-12 text-center">
|
||||||
@@ -100,12 +98,8 @@ const RingingState = ({ lead }: { lead: Lead | null }) => {
|
|||||||
<FontAwesomeIcon icon={faPhoneArrowDown} className="size-12 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faPhoneArrowDown} className="size-12 text-fg-brand-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
<span className="mb-1 text-xs font-bold tracking-wider text-brand-secondary uppercase">Incoming Call</span>
|
||||||
Incoming Call
|
<span className="text-display-xs font-bold text-primary">{phoneDisplay}</span>
|
||||||
</span>
|
|
||||||
<span className="text-display-xs font-bold text-primary">
|
|
||||||
{phoneDisplay}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -126,8 +120,8 @@ const ActiveState = ({
|
|||||||
activities
|
activities
|
||||||
.filter((a) => a.leadId === lead.id)
|
.filter((a) => a.leadId === lead.id)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const dateA = a.occurredAt ?? a.createdAt ?? '';
|
const dateA = a.occurredAt ?? a.createdAt ?? "";
|
||||||
const dateB = b.occurredAt ?? b.createdAt ?? '';
|
const dateB = b.occurredAt ?? b.createdAt ?? "";
|
||||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||||
})
|
})
|
||||||
.slice(0, 3),
|
.slice(0, 3),
|
||||||
@@ -140,13 +134,11 @@ const ActiveState = ({
|
|||||||
return campaign?.campaignName ?? null;
|
return campaign?.campaignName ?? null;
|
||||||
}, [campaigns, lead.campaignId]);
|
}, [campaigns, lead.campaignId]);
|
||||||
|
|
||||||
const firstName = lead.contactName?.firstName ?? '';
|
const firstName = lead.contactName?.firstName ?? "";
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
const lastName = lead.contactName?.lastName ?? "";
|
||||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
|
const fullName = `${firstName} ${lastName}`.trim() || "Unknown Lead";
|
||||||
const initials = firstName && lastName ? getInitials(firstName, lastName) : 'UL';
|
const initials = firstName && lastName ? getInitials(firstName, lastName) : "UL";
|
||||||
const phoneDisplay = lead.contactPhone?.[0]
|
const phoneDisplay = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "No phone";
|
||||||
? formatPhone(lead.contactPhone[0])
|
|
||||||
: 'No phone';
|
|
||||||
const emailDisplay = lead.contactEmail?.[0]?.address ?? null;
|
const emailDisplay = lead.contactEmail?.[0]?.address ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,18 +161,14 @@ const ActiveState = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
{lead.leadSource !== null && (
|
{lead.leadSource !== null && <SourceTag source={lead.leadSource} size="sm" />}
|
||||||
<SourceTag source={lead.leadSource} size="sm" />
|
|
||||||
)}
|
|
||||||
{campaignName !== null && (
|
{campaignName !== null && (
|
||||||
<Badge size="sm" color="brand">{campaignName}</Badge>
|
<Badge size="sm" color="brand">
|
||||||
|
{campaignName}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{lead.interestedService !== null && (
|
{lead.interestedService !== null && <p className="mt-1.5 text-sm text-secondary">Interested in: {lead.interestedService}</p>}
|
||||||
<p className="mt-1.5 text-sm text-secondary">
|
|
||||||
Interested in: {lead.interestedService}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{lead.createdAt !== null && (
|
{lead.createdAt !== null && (
|
||||||
<div className="mt-1 flex items-center gap-1.5 text-sm text-tertiary">
|
<div className="mt-1 flex items-center gap-1.5 text-sm text-tertiary">
|
||||||
<span>Lead age:</span>
|
<span>Lead age:</span>
|
||||||
@@ -194,9 +182,7 @@ const ActiveState = ({
|
|||||||
<div className="mt-4 rounded-xl bg-brand-primary p-4">
|
<div className="mt-4 rounded-xl bg-brand-primary p-4">
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<FontAwesomeIcon icon={faStars} className="size-4 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faStars} className="size-4 text-fg-brand-primary" />
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Insight</span>
|
||||||
AI Insight
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{lead.aiSummary !== null ? (
|
{lead.aiSummary !== null ? (
|
||||||
<>
|
<>
|
||||||
@@ -208,9 +194,7 @@ const ActiveState = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-quaternary">
|
<p className="text-sm text-quaternary">No AI insights available for this lead</p>
|
||||||
No AI insights available for this lead
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -221,14 +205,10 @@ const ActiveState = ({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{leadActivities.map((activity) => (
|
{leadActivities.map((activity) => (
|
||||||
<div key={activity.id} className="flex items-start gap-2">
|
<div key={activity.id} className="flex items-start gap-2">
|
||||||
<ActivityIcon type={activity.activityType ?? 'NOTE_ADDED'} />
|
<ActivityIcon type={activity.activityType ?? "NOTE_ADDED"} />
|
||||||
<span className="flex-1 text-xs text-secondary">
|
<span className="flex-1 text-xs text-secondary">{activity.summary}</span>
|
||||||
{activity.summary}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs text-quaternary">
|
<span className="shrink-0 text-xs text-quaternary">
|
||||||
{activity.occurredAt !== null
|
{activity.occurredAt !== null ? formatShortDate(activity.occurredAt) : ""}
|
||||||
? formatShortDate(activity.occurredAt)
|
|
||||||
: ''}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -240,7 +220,7 @@ const ActiveState = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right section: disposition form */}
|
{/* Right section: disposition form */}
|
||||||
<div className="w-full shrink-0 border-t border-secondary pt-4 lg:w-72 lg:border-l lg:border-t-0 lg:pl-6 lg:pt-0">
|
<div className="w-full shrink-0 border-t border-secondary pt-4 lg:w-72 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
||||||
<DispositionForm onSubmit={onDisposition} />
|
<DispositionForm onSubmit={onDisposition} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,14 +229,16 @@ const ActiveState = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CompletedState = ({ disposition }: { disposition: CallDisposition | null }) => {
|
const CompletedState = ({ disposition }: { disposition: CallDisposition | null }) => {
|
||||||
const label = disposition !== null ? dispositionLabels[disposition] : 'Unknown';
|
const label = disposition !== null ? dispositionLabels[disposition] : "Unknown";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-2xl bg-success-primary p-8 text-center">
|
<div className="flex flex-col items-center justify-center rounded-2xl bg-success-primary p-8 text-center">
|
||||||
<FontAwesomeIcon icon={faCircleCheck} className="mb-3 size-12 text-fg-success-primary" />
|
<FontAwesomeIcon icon={faCircleCheck} className="mb-3 size-12 text-fg-success-primary" />
|
||||||
<h3 className="text-lg font-bold text-success-primary">Call Logged</h3>
|
<h3 className="text-lg font-bold text-success-primary">Call Logged</h3>
|
||||||
{disposition !== null && (
|
{disposition !== null && (
|
||||||
<Badge size="md" color="success" className="mt-2">{label}</Badge>
|
<Badge size="md" color="success" className="mt-2">
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<p className="mt-2 text-sm text-tertiary">Returning to call desk...</p>
|
<p className="mt-2 text-sm text-tertiary">Returning to call desk...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { faCommentDots, faEllipsisVertical, faMessageDots, faPhone } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { faPhone, faCommentDots, faEllipsisVertical, faMessageDots } from '@fortawesome/pro-duotone-svg-icons';
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { notify } from "@/lib/toast";
|
||||||
import { notify } from '@/lib/toast';
|
import { useSip } from "@/providers/sip-provider";
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
type PhoneActionCellProps = {
|
type PhoneActionCellProps = {
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
@@ -27,8 +27,8 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
|||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handleClick);
|
document.addEventListener("mousedown", handleClick);
|
||||||
return () => document.removeEventListener('mousedown', handleClick);
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
const handleCall = async () => {
|
const handleCall = async () => {
|
||||||
@@ -39,7 +39,7 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
|||||||
onDial?.();
|
onDial?.();
|
||||||
await dialOutbound(phoneNumber);
|
await dialOutbound(phoneNumber);
|
||||||
} catch {
|
} catch {
|
||||||
notify.error('Dial Failed', 'Could not place the call');
|
notify.error("Dial Failed", "Could not place the call");
|
||||||
} finally {
|
} finally {
|
||||||
setDialing(false);
|
setDialing(false);
|
||||||
}
|
}
|
||||||
@@ -47,12 +47,12 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
|||||||
|
|
||||||
const handleSms = () => {
|
const handleSms = () => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
window.open(`sms:+91${phoneNumber}`, '_self');
|
window.open(`sms:+91${phoneNumber}`, "_self");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWhatsApp = () => {
|
const handleWhatsApp = () => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
window.open(`https://wa.me/91${phoneNumber}`, '_blank');
|
window.open(`https://wa.me/91${phoneNumber}`, "_blank");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Long-press for mobile
|
// Long-press for mobile
|
||||||
@@ -77,13 +77,14 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
|||||||
onClick={handleCall}
|
onClick={handleCall}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenuOpen(true);
|
||||||
|
}}
|
||||||
disabled={!canCall}
|
disabled={!canCall}
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear',
|
"flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear",
|
||||||
canCall
|
canCall ? "cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary" : "cursor-default text-tertiary",
|
||||||
? 'cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary'
|
|
||||||
: 'cursor-default text-tertiary',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
||||||
@@ -93,15 +94,18 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
|||||||
{/* Kebab menu trigger — desktop */}
|
{/* Kebab menu trigger — desktop */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
|
onClick={(e) => {
|
||||||
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary opacity-0 group-hover/row:opacity-100 hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
e.stopPropagation();
|
||||||
|
setMenuOpen(!menuOpen);
|
||||||
|
}}
|
||||||
|
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary opacity-0 transition duration-100 ease-linear group-hover/row:opacity-100 hover:bg-primary_hover hover:text-fg-secondary"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
|
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Context menu */}
|
{/* Context menu */}
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
<div className="absolute top-full left-0 z-50 mt-1 w-40 rounded-lg bg-primary py-1 shadow-lg ring-1 ring-secondary">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCall}
|
onClick={handleCall}
|
||||||
|
|||||||
@@ -1,61 +1,49 @@
|
|||||||
import { cx } from '@/utils/cx';
|
import { AdStatusBadge } from "@/components/shared/status-badge";
|
||||||
import { formatCurrency, formatCompact } from '@/lib/format';
|
import { formatCompact, formatCurrency } from "@/lib/format";
|
||||||
import { AdStatusBadge } from '@/components/shared/status-badge';
|
import type { Ad, AdFormat } from "@/types/entities";
|
||||||
import type { Ad, AdFormat } from '@/types/entities';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface AdCardProps {
|
interface AdCardProps {
|
||||||
ad: Ad;
|
ad: Ad;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPreviewStyles: Record<AdFormat, { bg: string; icon: string }> = {
|
const formatPreviewStyles: Record<AdFormat, { bg: string; icon: string }> = {
|
||||||
IMAGE: { bg: 'bg-brand-solid', icon: 'IMG' },
|
IMAGE: { bg: "bg-brand-solid", icon: "IMG" },
|
||||||
VIDEO: { bg: 'bg-fg-brand-secondary', icon: 'VID' },
|
VIDEO: { bg: "bg-fg-brand-secondary", icon: "VID" },
|
||||||
CAROUSEL: { bg: 'bg-error-solid', icon: 'CAR' },
|
CAROUSEL: { bg: "bg-error-solid", icon: "CAR" },
|
||||||
TEXT: { bg: 'bg-fg-tertiary', icon: 'TXT' },
|
TEXT: { bg: "bg-fg-tertiary", icon: "TXT" },
|
||||||
LEAD_FORM: { bg: 'bg-success-solid', icon: 'FORM' },
|
LEAD_FORM: { bg: "bg-success-solid", icon: "FORM" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatBadgeLabel = (format: AdFormat): string =>
|
const formatBadgeLabel = (format: AdFormat): string => format.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
format.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
||||||
|
|
||||||
export const AdCard = ({ ad }: AdCardProps) => {
|
export const AdCard = ({ ad }: AdCardProps) => {
|
||||||
const format = ad.adFormat ?? 'IMAGE';
|
const format = ad.adFormat ?? "IMAGE";
|
||||||
const preview = formatPreviewStyles[format] ?? formatPreviewStyles.IMAGE;
|
const preview = formatPreviewStyles[format] ?? formatPreviewStyles.IMAGE;
|
||||||
const currencyCode = ad.spend?.currencyCode ?? 'INR';
|
const currencyCode = ad.spend?.currencyCode ?? "INR";
|
||||||
|
|
||||||
const metrics = [
|
const metrics = [
|
||||||
{ label: 'Impr.', value: formatCompact(ad.impressions ?? 0) },
|
{ label: "Impr.", value: formatCompact(ad.impressions ?? 0) },
|
||||||
{ label: 'Clicks', value: formatCompact(ad.clicks ?? 0) },
|
{ label: "Clicks", value: formatCompact(ad.clicks ?? 0) },
|
||||||
{ label: 'Leads', value: String(ad.conversions ?? 0) },
|
{ label: "Leads", value: String(ad.conversions ?? 0) },
|
||||||
{ label: 'Spend', value: ad.spend ? formatCurrency(ad.spend.amountMicros, currencyCode) : '--' },
|
{ label: "Spend", value: ad.spend ? formatCurrency(ad.spend.amountMicros, currencyCode) : "--" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 rounded-xl border border-secondary bg-primary p-4 transition hover:shadow-sm">
|
<div className="flex items-center gap-4 rounded-xl border border-secondary bg-primary p-4 transition hover:shadow-sm">
|
||||||
{/* Preview thumbnail */}
|
{/* Preview thumbnail */}
|
||||||
<div
|
<div className={cx("flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white", preview.bg)}>
|
||||||
className={cx(
|
|
||||||
'flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white',
|
|
||||||
preview.bg,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{preview.icon}
|
{preview.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ad info */}
|
{/* Ad info */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h4 className="truncate text-sm font-bold text-primary">
|
<h4 className="truncate text-sm font-bold text-primary">{ad.adName ?? "Untitled Ad"}</h4>
|
||||||
{ad.adName ?? 'Untitled Ad'}
|
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-tertiary">{formatBadgeLabel(format)}</span>
|
||||||
</h4>
|
|
||||||
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-tertiary">
|
|
||||||
{formatBadgeLabel(format)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-xs text-quaternary">{ad.externalAdId ?? ad.id.slice(0, 12)}</p>
|
<p className="mt-0.5 text-xs text-quaternary">{ad.externalAdId ?? ad.id.slice(0, 12)}</p>
|
||||||
{ad.headline && (
|
{ad.headline && <p className="mt-1 truncate text-xs text-tertiary">{ad.headline}</p>}
|
||||||
<p className="mt-1 truncate text-xs text-tertiary">{ad.headline}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inline metrics */}
|
{/* Inline metrics */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cx } from '@/utils/cx';
|
import { formatCurrency } from "@/lib/format";
|
||||||
import { formatCurrency } from '@/lib/format';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
type CurrencyAmount = {
|
type CurrencyAmount = {
|
||||||
amountMicros: number;
|
amountMicros: number;
|
||||||
@@ -18,15 +18,10 @@ export const BudgetBar = ({ spent, budget }: BudgetBarProps) => {
|
|||||||
const ratio = budgetMicros > 0 ? spentMicros / budgetMicros : 0;
|
const ratio = budgetMicros > 0 ? spentMicros / budgetMicros : 0;
|
||||||
const percentage = Math.min(ratio * 100, 100);
|
const percentage = Math.min(ratio * 100, 100);
|
||||||
|
|
||||||
const fillColor =
|
const fillColor = ratio > 0.9 ? "bg-error-solid" : ratio > 0.7 ? "bg-warning-solid" : "bg-brand-solid";
|
||||||
ratio > 0.9
|
|
||||||
? 'bg-error-solid'
|
|
||||||
: ratio > 0.7
|
|
||||||
? 'bg-warning-solid'
|
|
||||||
: 'bg-brand-solid';
|
|
||||||
|
|
||||||
const spentDisplay = spent ? formatCurrency(spent.amountMicros, spent.currencyCode) : '--';
|
const spentDisplay = spent ? formatCurrency(spent.amountMicros, spent.currencyCode) : "--";
|
||||||
const budgetDisplay = budget ? formatCurrency(budget.amountMicros, budget.currencyCode) : '--';
|
const budgetDisplay = budget ? formatCurrency(budget.amountMicros, budget.currencyCode) : "--";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -36,11 +31,8 @@ export const BudgetBar = ({ spent, budget }: BudgetBarProps) => {
|
|||||||
{spentDisplay} / {budgetDisplay}
|
{spentDisplay} / {budgetDisplay}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 rounded-full bg-tertiary overflow-hidden">
|
<div className="h-1.5 overflow-hidden rounded-full bg-tertiary">
|
||||||
<div
|
<div className={cx("h-full rounded-full transition-all duration-300", fillColor)} style={{ width: `${percentage}%` }} />
|
||||||
className={cx('h-full rounded-full transition-all duration-300', fillColor)}
|
|
||||||
style={{ width: `${percentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { faPenToSquare } from '@fortawesome/pro-duotone-svg-icons';
|
import { faPenToSquare } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { SlideoutMenu } from "@/components/application/slideout-menus/slideout-menu";
|
||||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from "@/components/base/input/input";
|
||||||
import { Select } from '@/components/base/select/select';
|
import { Select } from "@/components/base/select/select";
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { faIcon } from "@/lib/icon-wrapper";
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from "@/lib/toast";
|
||||||
import type { Campaign, CampaignStatus } from '@/types/entities';
|
import type { Campaign, CampaignStatus } from "@/types/entities";
|
||||||
|
|
||||||
const PenIcon = faIcon(faPenToSquare);
|
const PenIcon = faIcon(faPenToSquare);
|
||||||
|
|
||||||
@@ -19,28 +19,28 @@ type CampaignEditSlideoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusItems = [
|
const statusItems = [
|
||||||
{ id: 'DRAFT' as const, label: 'Draft' },
|
{ id: "DRAFT" as const, label: "Draft" },
|
||||||
{ id: 'ACTIVE' as const, label: 'Active' },
|
{ id: "ACTIVE" as const, label: "Active" },
|
||||||
{ id: 'PAUSED' as const, label: 'Paused' },
|
{ id: "PAUSED" as const, label: "Paused" },
|
||||||
{ id: 'COMPLETED' as const, label: 'Completed' },
|
{ id: "COMPLETED" as const, label: "Completed" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const formatDateForInput = (dateStr: string | null): string => {
|
const formatDateForInput = (dateStr: string | null): string => {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return "";
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toISOString().slice(0, 10);
|
return new Date(dateStr).toISOString().slice(0, 10);
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const budgetToDisplay = (campaign: Campaign): string => {
|
const budgetToDisplay = (campaign: Campaign): string => {
|
||||||
if (!campaign.budget) return '';
|
if (!campaign.budget) return "";
|
||||||
return String(Math.round(campaign.budget.amountMicros / 1_000_000));
|
return String(Math.round(campaign.budget.amountMicros / 1_000_000));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }: CampaignEditSlideoutProps) => {
|
export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }: CampaignEditSlideoutProps) => {
|
||||||
const [campaignName, setCampaignName] = useState(campaign.campaignName ?? '');
|
const [campaignName, setCampaignName] = useState(campaign.campaignName ?? "");
|
||||||
const [status, setStatus] = useState<CampaignStatus | null>(campaign.campaignStatus);
|
const [status, setStatus] = useState<CampaignStatus | null>(campaign.campaignStatus);
|
||||||
const [budget, setBudget] = useState(budgetToDisplay(campaign));
|
const [budget, setBudget] = useState(budgetToDisplay(campaign));
|
||||||
const [startDate, setStartDate] = useState(formatDateForInput(campaign.startDate));
|
const [startDate, setStartDate] = useState(formatDateForInput(campaign.startDate));
|
||||||
@@ -65,7 +65,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
? {
|
? {
|
||||||
budget: {
|
budget: {
|
||||||
amountMicros: budgetMicros,
|
amountMicros: budgetMicros,
|
||||||
currencyCode: campaign.budget?.currencyCode ?? 'INR',
|
currencyCode: campaign.budget?.currencyCode ?? "INR",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -75,12 +75,12 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
notify.success('Campaign updated', `${campaignName || 'Campaign'} has been updated successfully.`);
|
notify.success("Campaign updated", `${campaignName || "Campaign"} has been updated successfully.`);
|
||||||
onSaved?.();
|
onSaved?.();
|
||||||
close();
|
close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// apiClient.graphql already toasts on error
|
// apiClient.graphql already toasts on error
|
||||||
console.error('Failed to update campaign:', err);
|
console.error("Failed to update campaign:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -104,12 +104,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
|
|
||||||
<SlideoutMenu.Content>
|
<SlideoutMenu.Content>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Input
|
<Input label="Campaign Name" placeholder="Enter campaign name" value={campaignName} onChange={setCampaignName} />
|
||||||
label="Campaign Name"
|
|
||||||
placeholder="Enter campaign name"
|
|
||||||
value={campaignName}
|
|
||||||
onChange={setCampaignName}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Status"
|
label="Status"
|
||||||
@@ -121,27 +116,11 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Input
|
<Input label="Budget (INR)" placeholder="e.g. 50000" type="number" value={budget} onChange={setBudget} />
|
||||||
label="Budget (INR)"
|
|
||||||
placeholder="e.g. 50000"
|
|
||||||
type="number"
|
|
||||||
value={budget}
|
|
||||||
onChange={setBudget}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input
|
<Input label="Start Date" type="date" value={startDate} onChange={setStartDate} />
|
||||||
label="Start Date"
|
<Input label="End Date" type="date" value={endDate} onChange={setEndDate} />
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={setStartDate}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="End Date"
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={setEndDate}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SlideoutMenu.Content>
|
</SlideoutMenu.Content>
|
||||||
@@ -151,14 +130,8 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
<Button size="md" color="secondary" onClick={close}>
|
<Button size="md" color="secondary" onClick={close}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="md" color="primary" isLoading={isSaving} showTextWhileLoading onClick={() => handleSave(close)}>
|
||||||
size="md"
|
{isSaving ? "Saving..." : "Save Changes"}
|
||||||
color="primary"
|
|
||||||
isLoading={isSaving}
|
|
||||||
showTextWhileLoading
|
|
||||||
onClick={() => handleSave(close)}
|
|
||||||
>
|
|
||||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SlideoutMenu.Footer>
|
</SlideoutMenu.Footer>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cx } from '@/utils/cx';
|
import type { Campaign, Lead } from "@/types/entities";
|
||||||
import type { Campaign, Lead } from '@/types/entities';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface ConversionFunnelProps {
|
interface ConversionFunnelProps {
|
||||||
campaign: Campaign;
|
campaign: Campaign;
|
||||||
@@ -15,14 +15,14 @@ type FunnelStep = {
|
|||||||
export const ConversionFunnel = ({ campaign, leads }: ConversionFunnelProps) => {
|
export const ConversionFunnel = ({ campaign, leads }: ConversionFunnelProps) => {
|
||||||
const leadCount = campaign.leadCount ?? 0;
|
const leadCount = campaign.leadCount ?? 0;
|
||||||
const contactedCount = campaign.contactedCount ?? 0;
|
const contactedCount = campaign.contactedCount ?? 0;
|
||||||
const appointmentCount = leads.filter((l) => l.leadStatus === 'APPOINTMENT_SET').length;
|
const appointmentCount = leads.filter((l) => l.leadStatus === "APPOINTMENT_SET").length;
|
||||||
const convertedCount = campaign.convertedCount ?? 0;
|
const convertedCount = campaign.convertedCount ?? 0;
|
||||||
|
|
||||||
const steps: FunnelStep[] = [
|
const steps: FunnelStep[] = [
|
||||||
{ label: 'Leads', count: leadCount, color: 'bg-brand-solid' },
|
{ label: "Leads", count: leadCount, color: "bg-brand-solid" },
|
||||||
{ label: 'Contacted', count: contactedCount, color: 'bg-brand-primary' },
|
{ label: "Contacted", count: contactedCount, color: "bg-brand-primary" },
|
||||||
{ label: 'Appointment Set', count: appointmentCount, color: 'bg-brand-primary_alt' },
|
{ label: "Appointment Set", count: appointmentCount, color: "bg-brand-primary_alt" },
|
||||||
{ label: 'Converted', count: convertedCount, color: 'bg-success-solid' },
|
{ label: "Converted", count: convertedCount, color: "bg-success-solid" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const maxCount = Math.max(...steps.map((s) => s.count), 1);
|
const maxCount = Math.max(...steps.map((s) => s.count), 1);
|
||||||
@@ -37,16 +37,14 @@ export const ConversionFunnel = ({ campaign, leads }: ConversionFunnelProps) =>
|
|||||||
<div key={step.label} className="flex items-center gap-3">
|
<div key={step.label} className="flex items-center gap-3">
|
||||||
<span className="w-24 shrink-0 text-xs text-tertiary">{step.label}</span>
|
<span className="w-24 shrink-0 text-xs text-tertiary">{step.label}</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="h-5 rounded bg-secondary overflow-hidden">
|
<div className="h-5 overflow-hidden rounded bg-secondary">
|
||||||
<div
|
<div
|
||||||
className={cx('h-full rounded transition-all duration-300', step.color)}
|
className={cx("h-full rounded transition-all duration-300", step.color)}
|
||||||
style={{ width: `${Math.max(widthPercent, 2)}%` }}
|
style={{ width: `${Math.max(widthPercent, 2)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-10 shrink-0 text-right text-xs font-bold text-primary">
|
<span className="w-10 shrink-0 text-right text-xs font-bold text-primary">{step.count}</span>
|
||||||
{step.count}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cx } from '@/utils/cx';
|
import type { Campaign, HealthStatus, Lead } from "@/types/entities";
|
||||||
import type { Campaign, Lead, HealthStatus } from '@/types/entities';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface HealthIndicatorProps {
|
interface HealthIndicatorProps {
|
||||||
campaign: Campaign;
|
campaign: Campaign;
|
||||||
@@ -7,8 +7,8 @@ interface HealthIndicatorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const computeHealth = (campaign: Campaign, _leads: Lead[]): { status: HealthStatus; reason: string } => {
|
const computeHealth = (campaign: Campaign, _leads: Lead[]): { status: HealthStatus; reason: string } => {
|
||||||
if (campaign.campaignStatus === 'PAUSED') {
|
if (campaign.campaignStatus === "PAUSED") {
|
||||||
return { status: 'UNHEALTHY', reason: 'Campaign is paused' };
|
return { status: "UNHEALTHY", reason: "Campaign is paused" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const leadCount = campaign.leadCount ?? 0;
|
const leadCount = campaign.leadCount ?? 0;
|
||||||
@@ -16,20 +16,20 @@ const computeHealth = (campaign: Campaign, _leads: Lead[]): { status: HealthStat
|
|||||||
const conversionRate = leadCount > 0 ? (convertedCount / leadCount) * 100 : 0;
|
const conversionRate = leadCount > 0 ? (convertedCount / leadCount) * 100 : 0;
|
||||||
|
|
||||||
if (conversionRate < 5) {
|
if (conversionRate < 5) {
|
||||||
return { status: 'UNHEALTHY', reason: `Low conversion rate (${conversionRate.toFixed(1)}%)` };
|
return { status: "UNHEALTHY", reason: `Low conversion rate (${conversionRate.toFixed(1)}%)` };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conversionRate < 10) {
|
if (conversionRate < 10) {
|
||||||
return { status: 'WARNING', reason: `Moderate conversion rate (${conversionRate.toFixed(1)}%)` };
|
return { status: "WARNING", reason: `Moderate conversion rate (${conversionRate.toFixed(1)}%)` };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: 'HEALTHY', reason: `Strong conversion rate (${conversionRate.toFixed(1)}%)` };
|
return { status: "HEALTHY", reason: `Strong conversion rate (${conversionRate.toFixed(1)}%)` };
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusStyles: Record<HealthStatus, { dot: string; text: string; label: string }> = {
|
const statusStyles: Record<HealthStatus, { dot: string; text: string; label: string }> = {
|
||||||
HEALTHY: { dot: 'bg-success-solid', text: 'text-success-primary', label: 'Healthy' },
|
HEALTHY: { dot: "bg-success-solid", text: "text-success-primary", label: "Healthy" },
|
||||||
WARNING: { dot: 'bg-warning-solid', text: 'text-warning-primary', label: 'Warning' },
|
WARNING: { dot: "bg-warning-solid", text: "text-warning-primary", label: "Warning" },
|
||||||
UNHEALTHY: { dot: 'bg-error-solid', text: 'text-error-primary', label: 'Unhealthy' },
|
UNHEALTHY: { dot: "bg-error-solid", text: "text-error-primary", label: "Unhealthy" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HealthIndicator = ({ campaign, leads }: HealthIndicatorProps) => {
|
export const HealthIndicator = ({ campaign, leads }: HealthIndicatorProps) => {
|
||||||
@@ -38,10 +38,10 @@ export const HealthIndicator = ({ campaign, leads }: HealthIndicatorProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cx('h-2.5 w-2.5 shrink-0 rounded-full', style.dot)} />
|
<span className={cx("h-2.5 w-2.5 shrink-0 rounded-full", style.dot)} />
|
||||||
<p className="text-xs text-tertiary">
|
<p className="text-xs text-tertiary">
|
||||||
<span className={cx('font-bold', style.text)}>{style.label}</span>
|
<span className={cx("font-bold", style.text)}>{style.label}</span>
|
||||||
{' \u2014 '}
|
{" \u2014 "}
|
||||||
{reason}
|
{reason}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cx } from '@/utils/cx';
|
import { formatCurrency } from "@/lib/format";
|
||||||
import { formatCurrency } from '@/lib/format';
|
import type { Campaign } from "@/types/entities";
|
||||||
import type { Campaign } from '@/types/entities';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface KpiStripProps {
|
interface KpiStripProps {
|
||||||
campaign: Campaign;
|
campaign: Campaign;
|
||||||
@@ -19,50 +19,50 @@ export const KpiStrip = ({ campaign }: KpiStripProps) => {
|
|||||||
const convertedCount = campaign.convertedCount ?? 0;
|
const convertedCount = campaign.convertedCount ?? 0;
|
||||||
const spentMicros = campaign.amountSpent?.amountMicros ?? 0;
|
const spentMicros = campaign.amountSpent?.amountMicros ?? 0;
|
||||||
const budgetMicros = campaign.budget?.amountMicros ?? 0;
|
const budgetMicros = campaign.budget?.amountMicros ?? 0;
|
||||||
const currencyCode = campaign.amountSpent?.currencyCode ?? 'INR';
|
const currencyCode = campaign.amountSpent?.currencyCode ?? "INR";
|
||||||
|
|
||||||
const contactRate = leadCount > 0 ? ((contactedCount / leadCount) * 100).toFixed(1) : '0.0';
|
const contactRate = leadCount > 0 ? ((contactedCount / leadCount) * 100).toFixed(1) : "0.0";
|
||||||
const conversionRate = leadCount > 0 ? ((convertedCount / leadCount) * 100).toFixed(1) : '0.0';
|
const conversionRate = leadCount > 0 ? ((convertedCount / leadCount) * 100).toFixed(1) : "0.0";
|
||||||
const budgetPercent = budgetMicros > 0 ? ((spentMicros / budgetMicros) * 100).toFixed(0) : '--';
|
const budgetPercent = budgetMicros > 0 ? ((spentMicros / budgetMicros) * 100).toFixed(0) : "--";
|
||||||
const costPerLead = leadCount > 0 ? formatCurrency(spentMicros / leadCount, currencyCode) : '--';
|
const costPerLead = leadCount > 0 ? formatCurrency(spentMicros / leadCount, currencyCode) : "--";
|
||||||
const cac = convertedCount > 0 ? formatCurrency(spentMicros / convertedCount, currencyCode) : '--';
|
const cac = convertedCount > 0 ? formatCurrency(spentMicros / convertedCount, currencyCode) : "--";
|
||||||
|
|
||||||
const items: KpiItem[] = [
|
const items: KpiItem[] = [
|
||||||
{
|
{
|
||||||
label: 'Total Leads',
|
label: "Total Leads",
|
||||||
value: String(leadCount),
|
value: String(leadCount),
|
||||||
subText: `${campaign.impressionCount ?? 0} impressions`,
|
subText: `${campaign.impressionCount ?? 0} impressions`,
|
||||||
subColor: 'text-tertiary',
|
subColor: "text-tertiary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Contacted',
|
label: "Contacted",
|
||||||
value: String(contactedCount),
|
value: String(contactedCount),
|
||||||
subText: `${contactRate}% contact rate`,
|
subText: `${contactRate}% contact rate`,
|
||||||
subColor: 'text-success-primary',
|
subColor: "text-success-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Converted',
|
label: "Converted",
|
||||||
value: String(convertedCount),
|
value: String(convertedCount),
|
||||||
subText: `${conversionRate}% conversion`,
|
subText: `${conversionRate}% conversion`,
|
||||||
subColor: 'text-success-primary',
|
subColor: "text-success-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Spent',
|
label: "Spent",
|
||||||
value: formatCurrency(spentMicros, currencyCode),
|
value: formatCurrency(spentMicros, currencyCode),
|
||||||
subText: `${budgetPercent}% of budget`,
|
subText: `${budgetPercent}% of budget`,
|
||||||
subColor: Number(budgetPercent) > 90 ? 'text-error-primary' : 'text-warning-primary',
|
subColor: Number(budgetPercent) > 90 ? "text-error-primary" : "text-warning-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Cost / Lead',
|
label: "Cost / Lead",
|
||||||
value: costPerLead,
|
value: costPerLead,
|
||||||
subText: 'avg per lead',
|
subText: "avg per lead",
|
||||||
subColor: 'text-tertiary',
|
subColor: "text-tertiary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'CAC',
|
label: "CAC",
|
||||||
value: cac,
|
value: cac,
|
||||||
subText: 'per conversion',
|
subText: "per conversion",
|
||||||
subColor: 'text-tertiary',
|
subColor: "text-tertiary",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -72,15 +72,15 @@ export const KpiStrip = ({ campaign }: KpiStripProps) => {
|
|||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex flex-1 flex-col justify-center px-4',
|
"flex flex-1 flex-col justify-center px-4",
|
||||||
index === 0 && 'pl-0',
|
index === 0 && "pl-0",
|
||||||
index === items.length - 1 && 'pr-0',
|
index === items.length - 1 && "pr-0",
|
||||||
index < items.length - 1 && 'border-r border-tertiary',
|
index < items.length - 1 && "border-r border-tertiary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="text-xl font-bold text-primary">{item.value}</p>
|
<p className="text-xl font-bold text-primary">{item.value}</p>
|
||||||
<p className="text-xs font-medium uppercase text-quaternary">{item.label}</p>
|
<p className="text-xs font-medium text-quaternary uppercase">{item.label}</p>
|
||||||
<p className={cx('mt-0.5 text-xs', item.subColor)}>{item.subText}</p>
|
<p className={cx("mt-0.5 text-xs", item.subColor)}>{item.subText}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
import { cx } from '@/utils/cx';
|
import type { Lead } from "@/types/entities";
|
||||||
import type { Lead } from '@/types/entities';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface SourceBreakdownProps {
|
interface SourceBreakdownProps {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceColors: Record<string, string> = {
|
const sourceColors: Record<string, string> = {
|
||||||
FACEBOOK_AD: 'bg-brand-solid',
|
FACEBOOK_AD: "bg-brand-solid",
|
||||||
GOOGLE_AD: 'bg-success-solid',
|
GOOGLE_AD: "bg-success-solid",
|
||||||
INSTAGRAM: 'bg-error-solid',
|
INSTAGRAM: "bg-error-solid",
|
||||||
GOOGLE_MY_BUSINESS: 'bg-warning-solid',
|
GOOGLE_MY_BUSINESS: "bg-warning-solid",
|
||||||
WEBSITE: 'bg-fg-brand-primary',
|
WEBSITE: "bg-fg-brand-primary",
|
||||||
REFERRAL: 'bg-fg-tertiary',
|
REFERRAL: "bg-fg-tertiary",
|
||||||
WHATSAPP: 'bg-success-solid',
|
WHATSAPP: "bg-success-solid",
|
||||||
WALK_IN: 'bg-fg-quaternary',
|
WALK_IN: "bg-fg-quaternary",
|
||||||
PHONE: 'bg-fg-secondary',
|
PHONE: "bg-fg-secondary",
|
||||||
OTHER: 'bg-fg-disabled',
|
OTHER: "bg-fg-disabled",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourceLabel = (source: string): string =>
|
const sourceLabel = (source: string): string => source.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
source.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
||||||
|
|
||||||
export const SourceBreakdown = ({ leads }: SourceBreakdownProps) => {
|
export const SourceBreakdown = ({ leads }: SourceBreakdownProps) => {
|
||||||
const sourceCounts = leads.reduce<Record<string, number>>((acc, lead) => {
|
const sourceCounts = leads.reduce<Record<string, number>>((acc, lead) => {
|
||||||
const source = lead.leadSource ?? 'OTHER';
|
const source = lead.leadSource ?? "OTHER";
|
||||||
acc[source] = (acc[source] ?? 0) + 1;
|
acc[source] = (acc[source] ?? 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -43,23 +42,16 @@ export const SourceBreakdown = ({ leads }: SourceBreakdownProps) => {
|
|||||||
const widthPercent = (count / maxCount) * 100;
|
const widthPercent = (count / maxCount) * 100;
|
||||||
return (
|
return (
|
||||||
<div key={source} className="flex items-center gap-3">
|
<div key={source} className="flex items-center gap-3">
|
||||||
<span className="w-28 shrink-0 truncate text-xs text-tertiary">
|
<span className="w-28 shrink-0 truncate text-xs text-tertiary">{sourceLabel(source)}</span>
|
||||||
{sourceLabel(source)}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="h-4 rounded bg-secondary overflow-hidden">
|
<div className="h-4 overflow-hidden rounded bg-secondary">
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx("h-full rounded transition-all duration-300", sourceColors[source] ?? "bg-fg-disabled")}
|
||||||
'h-full rounded transition-all duration-300',
|
|
||||||
sourceColors[source] ?? 'bg-fg-disabled',
|
|
||||||
)}
|
|
||||||
style={{ width: `${Math.max(widthPercent, 4)}%` }}
|
style={{ width: `${Math.max(widthPercent, 4)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-8 shrink-0 text-right text-xs font-bold text-primary">
|
<span className="w-8 shrink-0 text-right text-xs font-bold text-primary">{count}</span>
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { Link } from 'react-router';
|
import { faUserHeadset } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
import { Link } from "react-router";
|
||||||
import { Avatar } from '@/components/base/avatar/avatar';
|
import { Table, TableCard } from "@/components/application/table/table";
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { Table, TableCard } from '@/components/application/table/table';
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
import { getInitials } from '@/lib/format';
|
import { getInitials } from "@/lib/format";
|
||||||
import type { Call } from '@/types/entities';
|
import type { Call } from "@/types/entities";
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
const formatDuration = (seconds: number): string => {
|
||||||
if (seconds < 60) return `${seconds}s`;
|
if (seconds < 60) return `${seconds}s`;
|
||||||
@@ -16,7 +16,7 @@ const formatDuration = (seconds: number): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatPercent = (value: number): string => {
|
const formatPercent = (value: number): string => {
|
||||||
if (isNaN(value) || !isFinite(value)) return '0%';
|
if (isNaN(value) || !isFinite(value)) return "0%";
|
||||||
return `${Math.round(value)}%`;
|
return `${Math.round(value)}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,37 +28,44 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
|||||||
const agents = useMemo(() => {
|
const agents = useMemo(() => {
|
||||||
const agentMap = new Map<string, Call[]>();
|
const agentMap = new Map<string, Call[]>();
|
||||||
for (const call of calls) {
|
for (const call of calls) {
|
||||||
const agent = call.agentName ?? 'Unknown';
|
const agent = call.agentName ?? "Unknown";
|
||||||
if (!agentMap.has(agent)) agentMap.set(agent, []);
|
if (!agentMap.has(agent)) agentMap.set(agent, []);
|
||||||
agentMap.get(agent)!.push(call);
|
agentMap.get(agent)!.push(call);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(agentMap.entries()).map(([name, agentCalls]) => {
|
return Array.from(agentMap.entries())
|
||||||
const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
.map(([name, agentCalls]) => {
|
||||||
const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
const inbound = agentCalls.filter((c) => c.callDirection === "INBOUND").length;
|
||||||
const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
|
const outbound = agentCalls.filter((c) => c.callDirection === "OUTBOUND").length;
|
||||||
|
const missed = agentCalls.filter((c) => c.callStatus === "MISSED").length;
|
||||||
const total = agentCalls.length;
|
const total = agentCalls.length;
|
||||||
const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0);
|
const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0);
|
||||||
const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
||||||
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
||||||
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
const booked = agentCalls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length;
|
||||||
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
||||||
const nameParts = name.split(' ');
|
const nameParts = name.split(" ");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: name,
|
id: name,
|
||||||
name,
|
name,
|
||||||
initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''),
|
initials: getInitials(nameParts[0] ?? "", nameParts[1] ?? ""),
|
||||||
inbound, outbound, missed, total, avgHandle, conversion,
|
inbound,
|
||||||
|
outbound,
|
||||||
|
missed,
|
||||||
|
total,
|
||||||
|
avgHandle,
|
||||||
|
conversion,
|
||||||
};
|
};
|
||||||
}).sort((a, b) => b.total - a.total);
|
})
|
||||||
|
.sort((a, b) => b.total - a.total);
|
||||||
}, [calls]);
|
}, [calls]);
|
||||||
|
|
||||||
if (agents.length === 0) {
|
if (agents.length === 0) {
|
||||||
return (
|
return (
|
||||||
<TableCard.Root size="sm">
|
<TableCard.Root size="sm">
|
||||||
<TableCard.Header title="Agent Performance" description="Call metrics by agent" />
|
<TableCard.Header title="Agent Performance" description="Call metrics by agent" />
|
||||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
<div className="flex flex-col items-center justify-center gap-2 py-12">
|
||||||
<FontAwesomeIcon icon={faUserHeadset} className="size-8 text-fg-quaternary" />
|
<FontAwesomeIcon icon={faUserHeadset} className="size-8 text-fg-quaternary" />
|
||||||
<p className="text-sm text-tertiary">No agent data available</p>
|
<p className="text-sm text-tertiary">No agent data available</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,18 +92,32 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
|||||||
<Link to={`/agent/${encodeURIComponent(agent.name)}`} className="no-underline">
|
<Link to={`/agent/${encodeURIComponent(agent.name)}`} className="no-underline">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Avatar size="xs" initials={agent.initials} />
|
<Avatar size="xs" initials={agent.initials} />
|
||||||
<span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span>
|
<span className="text-sm font-medium text-brand-secondary transition duration-100 ease-linear hover:text-brand-secondary_hover">
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell><span className="text-sm text-success-primary">{agent.inbound}</span></Table.Cell>
|
|
||||||
<Table.Cell><span className="text-sm text-brand-secondary">{agent.outbound}</span></Table.Cell>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{agent.missed > 0 ? <Badge size="sm" color="error">{agent.missed}</Badge> : <span className="text-sm text-tertiary">0</span>}
|
<span className="text-sm text-success-primary">{agent.inbound}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell><span className="text-sm text-secondary">{formatDuration(agent.avgHandle)}</span></Table.Cell>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge size="sm" color={agent.conversion >= 30 ? 'success' : agent.conversion >= 15 ? 'warning' : 'gray'}>
|
<span className="text-sm text-brand-secondary">{agent.outbound}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{agent.missed > 0 ? (
|
||||||
|
<Badge size="sm" color="error">
|
||||||
|
{agent.missed}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-tertiary">0</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-sm text-secondary">{formatDuration(agent.avgHandle)}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge size="sm" color={agent.conversion >= 30 ? "success" : agent.conversion >= 15 ? "warning" : "gray"}>
|
||||||
{formatPercent(agent.conversion)}
|
{formatPercent(agent.conversion)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||||
import {
|
import { faCircleInfo, faPhone, faPhoneArrowDownLeft, faPhoneArrowUpRight, faPhoneMissed } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
faPhone,
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
faPhoneArrowDownLeft,
|
import type { Call, Lead } from "@/types/entities";
|
||||||
faPhoneArrowUpRight,
|
|
||||||
faPhoneMissed,
|
|
||||||
} from '@fortawesome/pro-duotone-svg-icons';
|
|
||||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
|
||||||
import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
|
|
||||||
import type { Call, Lead } from '@/types/entities';
|
|
||||||
|
|
||||||
type KpiCardProps = {
|
type KpiCardProps = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -24,7 +18,7 @@ const KpiCard = ({ label, value, icon, iconColor, iconBg, subtitle, tooltip }: K
|
|||||||
<div className={`flex size-10 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
|
<div className={`flex size-10 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
|
||||||
<FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} />
|
<FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-1 flex-col">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs font-medium text-tertiary">{label}</span>
|
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||||
{tooltip && <FontAwesomeIcon icon={faCircleInfo} className="size-3 text-fg-quaternary" title={tooltip} />}
|
{tooltip && <FontAwesomeIcon icon={faCircleInfo} className="size-3 text-fg-quaternary" title={tooltip} />}
|
||||||
@@ -54,12 +48,12 @@ const MetricCard = ({ label, value, description, tooltip }: MetricCardProps) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const formatPercent = (value: number): string => {
|
const formatPercent = (value: number): string => {
|
||||||
if (isNaN(value) || !isFinite(value)) return '0%';
|
if (isNaN(value) || !isFinite(value)) return "0%";
|
||||||
return `${Math.round(value)}%`;
|
return `${Math.round(value)}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatMinutes = (minutes: number | null): string => {
|
const formatMinutes = (minutes: number | null): string => {
|
||||||
if (minutes === null) return '—';
|
if (minutes === null) return "—";
|
||||||
if (minutes < 60) return `${minutes}m`;
|
if (minutes < 60) return `${minutes}m`;
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const mins = minutes % 60;
|
const mins = minutes % 60;
|
||||||
@@ -73,46 +67,96 @@ interface DashboardKpiProps {
|
|||||||
|
|
||||||
export const DashboardKpi = ({ calls, leads }: DashboardKpiProps) => {
|
export const DashboardKpi = ({ calls, leads }: DashboardKpiProps) => {
|
||||||
const totalCalls = calls.length;
|
const totalCalls = calls.length;
|
||||||
const inboundCalls = calls.filter((c) => c.callDirection === 'INBOUND').length;
|
const inboundCalls = calls.filter((c) => c.callDirection === "INBOUND").length;
|
||||||
const outboundCalls = calls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
const outboundCalls = calls.filter((c) => c.callDirection === "OUTBOUND").length;
|
||||||
const missedCalls = calls.filter((c) => c.callStatus === 'MISSED').length;
|
const missedCalls = calls.filter((c) => c.callStatus === "MISSED").length;
|
||||||
|
|
||||||
const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt);
|
const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt);
|
||||||
const avgResponseTime = leadsWithResponse.length > 0
|
const avgResponseTime =
|
||||||
? Math.round(leadsWithResponse.reduce((sum, l) => {
|
leadsWithResponse.length > 0
|
||||||
|
? Math.round(
|
||||||
|
leadsWithResponse.reduce((sum, l) => {
|
||||||
const diff = Math.abs(new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000;
|
const diff = Math.abs(new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000;
|
||||||
return sum + diff;
|
return sum + diff;
|
||||||
}, 0) / leadsWithResponse.length)
|
}, 0) / leadsWithResponse.length,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const missedCallsList = calls.filter((c) => c.callStatus === 'MISSED' && c.startedAt);
|
const missedCallsList = calls.filter((c) => c.callStatus === "MISSED" && c.startedAt);
|
||||||
const missedCallbackTime = missedCallsList.length > 0
|
// eslint-disable-next-line react-hooks/purity
|
||||||
? Math.round(missedCallsList.reduce((sum, c) => sum + (Date.now() - new Date(c.startedAt!).getTime()) / 60000, 0) / missedCallsList.length)
|
const renderTime = Date.now();
|
||||||
|
const missedCallbackTime =
|
||||||
|
missedCallsList.length > 0
|
||||||
|
? Math.round(missedCallsList.reduce((sum, c) => sum + (renderTime - new Date(c.startedAt!).getTime()) / 60000, 0) / missedCallsList.length)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const callToAppt = totalCalls > 0
|
const callToAppt = totalCalls > 0 ? (calls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length / totalCalls) * 100 : 0;
|
||||||
? (calls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length / totalCalls) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const leadToAppt = leads.length > 0
|
const leadToAppt =
|
||||||
? (leads.filter((l) => l.leadStatus === 'APPOINTMENT_SET' || l.leadStatus === 'CONVERTED').length / leads.length) * 100
|
leads.length > 0 ? (leads.filter((l) => l.leadStatus === "APPOINTMENT_SET" || l.leadStatus === "CONVERTED").length / leads.length) * 100 : 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||||
<KpiCard label="Total Calls" value={totalCalls} icon={faPhone} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" tooltip="Total inbound + outbound calls in the selected period" />
|
<KpiCard
|
||||||
<KpiCard label="Inbound" value={inboundCalls} icon={faPhoneArrowDownLeft} iconColor="text-fg-success-primary" iconBg="bg-success-secondary" tooltip="Calls received from patients/leads" />
|
label="Total Calls"
|
||||||
<KpiCard label="Outbound" value={outboundCalls} icon={faPhoneArrowUpRight} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" tooltip="Calls made by agents to patients/leads" />
|
value={totalCalls}
|
||||||
<KpiCard label="Missed" value={missedCalls} icon={faPhoneMissed} iconColor="text-fg-error-primary" iconBg="bg-error-secondary"
|
icon={faPhone}
|
||||||
|
iconColor="text-fg-brand-primary"
|
||||||
|
iconBg="bg-brand-secondary"
|
||||||
|
tooltip="Total inbound + outbound calls in the selected period"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Inbound"
|
||||||
|
value={inboundCalls}
|
||||||
|
icon={faPhoneArrowDownLeft}
|
||||||
|
iconColor="text-fg-success-primary"
|
||||||
|
iconBg="bg-success-secondary"
|
||||||
|
tooltip="Calls received from patients/leads"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Outbound"
|
||||||
|
value={outboundCalls}
|
||||||
|
icon={faPhoneArrowUpRight}
|
||||||
|
iconColor="text-fg-brand-primary"
|
||||||
|
iconBg="bg-brand-secondary"
|
||||||
|
tooltip="Calls made by agents to patients/leads"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Missed"
|
||||||
|
value={missedCalls}
|
||||||
|
icon={faPhoneMissed}
|
||||||
|
iconColor="text-fg-error-primary"
|
||||||
|
iconBg="bg-error-secondary"
|
||||||
subtitle={totalCalls > 0 ? `${formatPercent((missedCalls / totalCalls) * 100)} of total` : undefined}
|
subtitle={totalCalls > 0 ? `${formatPercent((missedCalls / totalCalls) * 100)} of total` : undefined}
|
||||||
tooltip="Inbound calls that were not answered by any agent" />
|
tooltip="Inbound calls that were not answered by any agent"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||||
<MetricCard label="Avg Response" value={formatMinutes(avgResponseTime)} description="Lead creation to first contact" tooltip="Average time between a lead being created and an agent making first contact" />
|
<MetricCard
|
||||||
<MetricCard label="Missed Callback" value={formatMinutes(missedCallbackTime)} description="Avg wait for missed callbacks" tooltip="Average time missed calls have been waiting for a callback" />
|
label="Avg Response"
|
||||||
<MetricCard label="Call → Appt" value={formatPercent(callToAppt)} description="Calls resulting in bookings" tooltip="Percentage of calls where the outcome was an appointment booking" />
|
value={formatMinutes(avgResponseTime)}
|
||||||
<MetricCard label="Lead → Appt" value={formatPercent(leadToAppt)} description="Leads converted to appointments" tooltip="Percentage of leads that reached APPOINTMENT_SET or CONVERTED status" />
|
description="Lead creation to first contact"
|
||||||
|
tooltip="Average time between a lead being created and an agent making first contact"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Missed Callback"
|
||||||
|
value={formatMinutes(missedCallbackTime)}
|
||||||
|
description="Avg wait for missed callbacks"
|
||||||
|
tooltip="Average time missed calls have been waiting for a callback"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Call → Appt"
|
||||||
|
value={formatPercent(callToAppt)}
|
||||||
|
description="Calls resulting in bookings"
|
||||||
|
tooltip="Percentage of calls where the outcome was an appointment booking"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Lead → Appt"
|
||||||
|
value={formatPercent(leadToAppt)}
|
||||||
|
description="Leads converted to appointments"
|
||||||
|
tooltip="Percentage of leads that reached APPOINTMENT_SET or CONVERTED status"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { faPhoneMissed } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { faPhoneMissed } from '@fortawesome/pro-duotone-svg-icons';
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
import { ClickToCallButton } from "@/components/call-desk/click-to-call-button";
|
||||||
import type { Call } from '@/types/entities';
|
import type { Call } from "@/types/entities";
|
||||||
|
|
||||||
const getTimeSince = (dateStr: string | null): string => {
|
const getTimeSince = (dateStr: string | null): string => {
|
||||||
if (!dateStr) return '—';
|
if (!dateStr) return "—";
|
||||||
const mins = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000);
|
const mins = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||||
if (mins < 1) return 'Just now';
|
if (mins < 1) return "Just now";
|
||||||
if (mins < 60) return `${mins}m ago`;
|
if (mins < 60) return `${mins}m ago`;
|
||||||
const hours = Math.floor(mins / 60);
|
const hours = Math.floor(mins / 60);
|
||||||
if (hours < 24) return `${hours}h ago`;
|
if (hours < 24) return `${hours}h ago`;
|
||||||
@@ -22,7 +22,7 @@ interface MissedQueueProps {
|
|||||||
export const MissedQueue = ({ calls }: MissedQueueProps) => {
|
export const MissedQueue = ({ calls }: MissedQueueProps) => {
|
||||||
const missedCalls = useMemo(() => {
|
const missedCalls = useMemo(() => {
|
||||||
return calls
|
return calls
|
||||||
.filter((c) => c.callStatus === 'MISSED')
|
.filter((c) => c.callStatus === "MISSED")
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||||
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||||
@@ -39,22 +39,27 @@ export const MissedQueue = ({ calls }: MissedQueueProps) => {
|
|||||||
<h3 className="text-sm font-semibold text-primary">Missed Call Queue</h3>
|
<h3 className="text-sm font-semibold text-primary">Missed Call Queue</h3>
|
||||||
</div>
|
</div>
|
||||||
{missedCalls.length > 0 && (
|
{missedCalls.length > 0 && (
|
||||||
<Badge size="sm" color="error">{missedCalls.length}</Badge>
|
<Badge size="sm" color="error">
|
||||||
|
{missedCalls.length}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[500px] overflow-y-auto">
|
<div className="max-h-[500px] overflow-y-auto">
|
||||||
{missedCalls.length === 0 ? (
|
{missedCalls.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-10 gap-2">
|
<div className="flex flex-col items-center justify-center gap-2 py-10">
|
||||||
<FontAwesomeIcon icon={faPhoneMissed} className="size-6 text-fg-quaternary" />
|
<FontAwesomeIcon icon={faPhoneMissed} className="size-6 text-fg-quaternary" />
|
||||||
<p className="text-sm text-tertiary">No missed calls</p>
|
<p className="text-sm text-tertiary">No missed calls</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-secondary">
|
<ul className="divide-y divide-secondary">
|
||||||
{missedCalls.map((call) => {
|
{missedCalls.map((call) => {
|
||||||
const phone = call.callerNumber?.[0]?.number ?? '';
|
const phone = call.callerNumber?.[0]?.number ?? "";
|
||||||
const display = phone ? `+91 ${phone}` : 'Unknown';
|
const display = phone ? `+91 ${phone}` : "Unknown";
|
||||||
return (
|
return (
|
||||||
<li key={call.id} className="flex items-center justify-between px-4 py-2.5 hover:bg-primary_hover transition duration-100 ease-linear">
|
<li
|
||||||
|
key={call.id}
|
||||||
|
className="flex items-center justify-between px-4 py-2.5 transition duration-100 ease-linear hover:bg-primary_hover"
|
||||||
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium text-primary">{display}</span>
|
<span className="text-sm font-medium text-primary">{display}</span>
|
||||||
<span className="text-xs text-tertiary">{getTimeSince(call.startedAt)}</span>
|
<span className="text-xs text-tertiary">{getTimeSince(call.startedAt)}</span>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { FC, ReactNode, Ref } from "react";
|
import { type FC, type ReactNode, type Ref, isValidElement } from "react";
|
||||||
import { isValidElement } from "react";
|
|
||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
import { isReactComponent } from "@/utils/is-react-component";
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { SVGProps } from "react";
|
import { type SVGProps, useId } from "react";
|
||||||
import { useId } from "react";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
export const UntitledLogoMinimal = (props: SVGProps<SVGSVGElement>) => {
|
export const UntitledLogoMinimal = (props: SVGProps<SVGSVGElement>) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { HTMLAttributes, SVGProps } from "react";
|
import { type HTMLAttributes, type SVGProps, useId } from "react";
|
||||||
import { useId } from "react";
|
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const getStarProgress = (starPosition: number, rating: number, maxRating: number = 5) => {
|
export const getStarProgress = (starPosition: number, rating: number, maxRating: number = 5) => {
|
||||||
// Ensure rating is between 0 and 5
|
// Ensure rating is between 0 and 5
|
||||||
const clampedRating = Math.min(Math.max(rating, 0), maxRating);
|
const clampedRating = Math.min(Math.max(rating, 0), maxRating);
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { faCopy, faGear, faLink } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { faGear, faCopy, faLink } from '@fortawesome/pro-duotone-svg-icons';
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { SlideoutMenu } from "@/components/application/slideout-menus/slideout-menu";
|
||||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from "@/components/base/input/input";
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { faIcon } from "@/lib/icon-wrapper";
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from "@/lib/toast";
|
||||||
|
|
||||||
const GearIcon = faIcon(faGear);
|
const GearIcon = faIcon(faGear);
|
||||||
|
|
||||||
type IntegrationType = 'ozonetel' | 'whatsapp' | 'facebook' | 'google' | 'instagram' | 'website' | 'email';
|
type IntegrationType = "ozonetel" | "whatsapp" | "facebook" | "google" | "instagram" | "website" | "email";
|
||||||
|
|
||||||
type IntegrationConfig = {
|
type IntegrationConfig = {
|
||||||
type: IntegrationType;
|
type: IntegrationType;
|
||||||
@@ -36,39 +36,39 @@ type FieldDef = {
|
|||||||
|
|
||||||
const getFieldsForType = (integration: IntegrationConfig): FieldDef[] => {
|
const getFieldsForType = (integration: IntegrationConfig): FieldDef[] => {
|
||||||
switch (integration.type) {
|
switch (integration.type) {
|
||||||
case 'ozonetel':
|
case "ozonetel":
|
||||||
return [
|
return [
|
||||||
{ key: 'account', label: 'Account ID', placeholder: 'e.g. global_healthx' },
|
{ key: "account", label: "Account ID", placeholder: "e.g. global_healthx" },
|
||||||
{ key: 'apiKey', label: 'API Key', placeholder: 'Enter API key', type: 'password' },
|
{ key: "apiKey", label: "API Key", placeholder: "Enter API key", type: "password" },
|
||||||
{ key: 'agentId', label: 'Agent ID', placeholder: 'e.g. global' },
|
{ key: "agentId", label: "Agent ID", placeholder: "e.g. global" },
|
||||||
{ key: 'sipId', label: 'SIP ID / Extension', placeholder: 'e.g. 523590' },
|
{ key: "sipId", label: "SIP ID / Extension", placeholder: "e.g. 523590" },
|
||||||
{ key: 'campaign', label: 'Campaign Name', placeholder: 'e.g. Inbound_918041763265' },
|
{ key: "campaign", label: "Campaign Name", placeholder: "e.g. Inbound_918041763265" },
|
||||||
];
|
];
|
||||||
case 'whatsapp':
|
case "whatsapp":
|
||||||
return [
|
return [
|
||||||
{ key: 'apiKey', label: 'API Key', placeholder: 'Enter WhatsApp API key', type: 'password' },
|
{ key: "apiKey", label: "API Key", placeholder: "Enter WhatsApp API key", type: "password" },
|
||||||
{ key: 'phoneNumberId', label: 'Phone Number ID', placeholder: 'e.g. 123456789012345' },
|
{ key: "phoneNumberId", label: "Phone Number ID", placeholder: "e.g. 123456789012345" },
|
||||||
];
|
];
|
||||||
case 'facebook':
|
case "facebook":
|
||||||
case 'google':
|
case "google":
|
||||||
case 'instagram':
|
case "instagram":
|
||||||
return [];
|
return [];
|
||||||
case 'website':
|
case "website":
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: 'webhookUrl',
|
key: "webhookUrl",
|
||||||
label: 'Webhook URL',
|
label: "Webhook URL",
|
||||||
placeholder: '',
|
placeholder: "",
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
copyable: true,
|
copyable: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
case 'email':
|
case "email":
|
||||||
return [
|
return [
|
||||||
{ key: 'smtpHost', label: 'SMTP Host', placeholder: 'e.g. smtp.gmail.com' },
|
{ key: "smtpHost", label: "SMTP Host", placeholder: "e.g. smtp.gmail.com" },
|
||||||
{ key: 'smtpPort', label: 'Port', placeholder: 'e.g. 587' },
|
{ key: "smtpPort", label: "Port", placeholder: "e.g. 587" },
|
||||||
{ key: 'smtpUser', label: 'Username', placeholder: 'e.g. noreply@clinic.com' },
|
{ key: "smtpUser", label: "Username", placeholder: "e.g. noreply@clinic.com" },
|
||||||
{ key: 'smtpPassword', label: 'Password', placeholder: 'Enter SMTP password', type: 'password' },
|
{ key: "smtpPassword", label: "Password", placeholder: "Enter SMTP password", type: "password" },
|
||||||
];
|
];
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
@@ -80,25 +80,25 @@ const getInitialValues = (integration: IntegrationConfig): Record<string, string
|
|||||||
const detailMap = new Map(integration.details.map((d) => [d.label, d.value]));
|
const detailMap = new Map(integration.details.map((d) => [d.label, d.value]));
|
||||||
|
|
||||||
switch (integration.type) {
|
switch (integration.type) {
|
||||||
case 'ozonetel':
|
case "ozonetel":
|
||||||
values.account = detailMap.get('Account') ?? '';
|
values.account = detailMap.get("Account") ?? "";
|
||||||
values.apiKey = '';
|
values.apiKey = "";
|
||||||
values.agentId = detailMap.get('Agent ID') ?? '';
|
values.agentId = detailMap.get("Agent ID") ?? "";
|
||||||
values.sipId = detailMap.get('SIP Extension') ?? '';
|
values.sipId = detailMap.get("SIP Extension") ?? "";
|
||||||
values.campaign = detailMap.get('Inbound Campaign') ?? '';
|
values.campaign = detailMap.get("Inbound Campaign") ?? "";
|
||||||
break;
|
break;
|
||||||
case 'whatsapp':
|
case "whatsapp":
|
||||||
values.apiKey = '';
|
values.apiKey = "";
|
||||||
values.phoneNumberId = '';
|
values.phoneNumberId = "";
|
||||||
break;
|
break;
|
||||||
case 'website':
|
case "website":
|
||||||
values.webhookUrl = integration.webhookUrl ?? '';
|
values.webhookUrl = integration.webhookUrl ?? "";
|
||||||
break;
|
break;
|
||||||
case 'email':
|
case "email":
|
||||||
values.smtpHost = '';
|
values.smtpHost = "";
|
||||||
values.smtpPort = '587';
|
values.smtpPort = "587";
|
||||||
values.smtpUser = '';
|
values.smtpUser = "";
|
||||||
values.smtpPassword = '';
|
values.smtpPassword = "";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -107,8 +107,7 @@ const getInitialValues = (integration: IntegrationConfig): Record<string, string
|
|||||||
return values;
|
return values;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOAuthType = (type: IntegrationType): boolean =>
|
const isOAuthType = (type: IntegrationType): boolean => type === "facebook" || type === "google" || type === "instagram";
|
||||||
type === 'facebook' || type === 'google' || type === 'instagram';
|
|
||||||
|
|
||||||
export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: IntegrationEditSlideoutProps) => {
|
export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: IntegrationEditSlideoutProps) => {
|
||||||
const fields = getFieldsForType(integration);
|
const fields = getFieldsForType(integration);
|
||||||
@@ -120,16 +119,16 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
|
|||||||
|
|
||||||
const handleCopy = (value: string) => {
|
const handleCopy = (value: string) => {
|
||||||
navigator.clipboard.writeText(value);
|
navigator.clipboard.writeText(value);
|
||||||
notify.success('Copied', 'Value copied to clipboard');
|
notify.success("Copied", "Value copied to clipboard");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = (close: () => void) => {
|
const handleSave = (close: () => void) => {
|
||||||
notify.success('Configuration saved', `${integration.name} configuration has been saved.`);
|
notify.success("Configuration saved", `${integration.name} configuration has been saved.`);
|
||||||
close();
|
close();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOAuthConnect = () => {
|
const handleOAuthConnect = () => {
|
||||||
notify.info('OAuth Connect', `${integration.name} OAuth flow is not yet implemented. This is a placeholder.`);
|
notify.info("OAuth Connect", `${integration.name} OAuth flow is not yet implemented. This is a placeholder.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -161,12 +160,7 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
|
|||||||
Authorize Helix Engage to access your {integration.name} account to import leads automatically.
|
Authorize Helix Engage to access your {integration.name} account to import leads automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button size="md" color="primary" iconLeading={faIcon(faLink)} onClick={handleOAuthConnect}>
|
||||||
size="md"
|
|
||||||
color="primary"
|
|
||||||
iconLeading={faIcon(faLink)}
|
|
||||||
onClick={handleOAuthConnect}
|
|
||||||
>
|
|
||||||
Connect {integration.name}
|
Connect {integration.name}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,14 +171,12 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-secondary">{field.label}</label>
|
<label className="text-sm font-medium text-secondary">{field.label}</label>
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-secondary p-3">
|
<div className="flex items-center gap-2 rounded-lg bg-secondary p-3">
|
||||||
<code className="flex-1 truncate text-xs text-secondary">
|
<code className="flex-1 truncate text-xs text-secondary">{values[field.key] || "\u2014"}</code>
|
||||||
{values[field.key] || '\u2014'}
|
|
||||||
</code>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
iconLeading={faIcon(faCopy)}
|
iconLeading={faIcon(faCopy)}
|
||||||
onClick={() => handleCopy(values[field.key] ?? '')}
|
onClick={() => handleCopy(values[field.key] ?? "")}
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
</Button>
|
</Button>
|
||||||
@@ -195,7 +187,7 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
|
|||||||
label={field.label}
|
label={field.label}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
type={field.type}
|
type={field.type}
|
||||||
value={values[field.key] ?? ''}
|
value={values[field.key] ?? ""}
|
||||||
onChange={(value) => updateValue(field.key, value)}
|
onChange={(value) => updateValue(field.key, value)}
|
||||||
isDisabled={field.readOnly}
|
isDisabled={field.readOnly}
|
||||||
/>
|
/>
|
||||||
@@ -212,11 +204,7 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
{!isOAuthType(integration.type) && (
|
{!isOAuthType(integration.type) && (
|
||||||
<Button
|
<Button size="md" color="primary" onClick={() => handleSave(close)}>
|
||||||
size="md"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => handleSave(close)}
|
|
||||||
>
|
|
||||||
Save Configuration
|
Save Configuration
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Navigate, Outlet } from 'react-router';
|
import { Navigate, Outlet } from "react-router";
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
|
||||||
export const AuthGuard = () => {
|
export const AuthGuard = () => {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useAuth } from '@/providers/auth-provider';
|
import { CallDeskPage } from "@/pages/call-desk";
|
||||||
import { LeadWorkspacePage } from '@/pages/lead-workspace';
|
import { LeadWorkspacePage } from "@/pages/lead-workspace";
|
||||||
import { TeamDashboardPage } from '@/pages/team-dashboard';
|
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||||
import { CallDeskPage } from '@/pages/call-desk';
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
|
||||||
export const RoleRouter = () => {
|
export const RoleRouter = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
switch (user.role) {
|
switch (user.role) {
|
||||||
case 'admin':
|
case "admin":
|
||||||
return <TeamDashboardPage />;
|
return <TeamDashboardPage />;
|
||||||
case 'cc-agent':
|
case "cc-agent":
|
||||||
return <CallDeskPage />;
|
return <CallDeskPage />;
|
||||||
default:
|
default:
|
||||||
return <LeadWorkspacePage />;
|
return <LeadWorkspacePage />;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cx } from '@/utils/cx';
|
import { daysAgoFromNow } from "@/lib/format";
|
||||||
import { daysAgoFromNow } from '@/lib/format';
|
import type { Lead } from "@/types/entities";
|
||||||
import type { Lead } from '@/types/entities';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface AgingWidgetProps {
|
interface AgingWidgetProps {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
@@ -26,21 +26,21 @@ export const AgingWidget = ({ leads }: AgingWidgetProps) => {
|
|||||||
|
|
||||||
const brackets: AgingBracket[] = [
|
const brackets: AgingBracket[] = [
|
||||||
{
|
{
|
||||||
label: 'Fresh (<2 days)',
|
label: "Fresh (<2 days)",
|
||||||
color: 'text-success-primary',
|
color: "text-success-primary",
|
||||||
barColor: 'bg-success-solid',
|
barColor: "bg-success-solid",
|
||||||
count: freshCount,
|
count: freshCount,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Warm (2-5 days)',
|
label: "Warm (2-5 days)",
|
||||||
color: 'text-warning-primary',
|
color: "text-warning-primary",
|
||||||
barColor: 'bg-warning-solid',
|
barColor: "bg-warning-solid",
|
||||||
count: warmCount,
|
count: warmCount,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Cold (>5 days)',
|
label: "Cold (>5 days)",
|
||||||
color: 'text-error-primary',
|
color: "text-error-primary",
|
||||||
barColor: 'bg-error-solid',
|
barColor: "bg-error-solid",
|
||||||
count: coldCount,
|
count: coldCount,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -52,14 +52,12 @@ export const AgingWidget = ({ leads }: AgingWidgetProps) => {
|
|||||||
{brackets.map((bracket) => (
|
{brackets.map((bracket) => (
|
||||||
<div key={bracket.label}>
|
<div key={bracket.label}>
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<span className={cx('text-xs', bracket.color)}>{bracket.label}</span>
|
<span className={cx("text-xs", bracket.color)}>{bracket.label}</span>
|
||||||
<span className={cx('text-sm font-bold', bracket.color)}>
|
<span className={cx("text-sm font-bold", bracket.color)}>{bracket.count}</span>
|
||||||
{bracket.count}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-tertiary">
|
<div className="h-2 overflow-hidden rounded-full bg-tertiary">
|
||||||
<div
|
<div
|
||||||
className={cx('h-full rounded-full transition-all', bracket.barColor)}
|
className={cx("h-full rounded-full transition-all", bracket.barColor)}
|
||||||
style={{ width: `${(bracket.count / total) * 100}%` }}
|
style={{ width: `${(bracket.count / total) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,21 @@
|
|||||||
import { daysAgoFromNow } from '@/lib/format';
|
import { daysAgoFromNow } from "@/lib/format";
|
||||||
import type { Lead } from '@/types/entities';
|
import type { Lead } from "@/types/entities";
|
||||||
|
|
||||||
interface AlertsWidgetProps {
|
interface AlertsWidgetProps {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AlertsWidget = ({ leads }: AlertsWidgetProps) => {
|
export const AlertsWidget = ({ leads }: AlertsWidgetProps) => {
|
||||||
const agingCount = leads.filter(
|
const agingCount = leads.filter((l) => l.leadStatus === "NEW" && l.createdAt !== null && daysAgoFromNow(l.createdAt) > 5).length;
|
||||||
(l) =>
|
|
||||||
l.leadStatus === 'NEW' &&
|
|
||||||
l.createdAt !== null &&
|
|
||||||
daysAgoFromNow(l.createdAt) > 5,
|
|
||||||
).length;
|
|
||||||
|
|
||||||
if (agingCount === 0) {
|
if (agingCount === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-error-subtle bg-error-primary p-4">
|
<div className="border-error-subtle rounded-xl border bg-error-primary p-4">
|
||||||
<p className="text-xs font-bold text-error-primary">
|
<p className="text-xs font-bold text-error-primary">{agingCount} leads aging > 5 days</p>
|
||||||
{agingCount} leads aging > 5 days
|
<p className="mt-1 text-xs text-error-primary opacity-80">These leads haven't been contacted and are at risk of going cold.</p>
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-error-primary opacity-80">
|
|
||||||
These leads haven't been contacted and are at risk of going cold.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,39 +9,23 @@ interface BulkActionBarProps {
|
|||||||
export const BulkActionBar = ({ selectedCount, onAssign, onWhatsApp, onMarkSpam, onDeselect }: BulkActionBarProps) => {
|
export const BulkActionBar = ({ selectedCount, onAssign, onWhatsApp, onMarkSpam, onDeselect }: BulkActionBarProps) => {
|
||||||
if (selectedCount === 0) return null;
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
const buttonBase = 'rounded-lg px-3 py-1.5 text-xs font-semibold border-none cursor-pointer transition duration-100 ease-linear';
|
const buttonBase = "rounded-lg px-3 py-1.5 text-xs font-semibold border-none cursor-pointer transition duration-100 ease-linear";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-xl bg-brand-section p-3 text-white">
|
<div className="flex items-center gap-3 rounded-xl bg-brand-section p-3 text-white">
|
||||||
<span className="text-sm font-semibold">{selectedCount} leads selected</span>
|
<span className="text-sm font-semibold">{selectedCount} leads selected</span>
|
||||||
|
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex gap-2">
|
||||||
<button
|
<button type="button" onClick={onAssign} className={`${buttonBase} bg-white/20 text-white hover:bg-white/30`}>
|
||||||
type="button"
|
|
||||||
onClick={onAssign}
|
|
||||||
className={`${buttonBase} bg-white/20 text-white hover:bg-white/30`}
|
|
||||||
>
|
|
||||||
Assign to Call Center
|
Assign to Call Center
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={onWhatsApp} className={`${buttonBase} bg-success-solid text-white hover:bg-success-solid/90`}>
|
||||||
type="button"
|
|
||||||
onClick={onWhatsApp}
|
|
||||||
className={`${buttonBase} bg-success-solid text-white hover:bg-success-solid/90`}
|
|
||||||
>
|
|
||||||
Send WhatsApp
|
Send WhatsApp
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={onMarkSpam} className={`${buttonBase} bg-white/15 text-error-primary hover:bg-white/25`}>
|
||||||
type="button"
|
|
||||||
onClick={onMarkSpam}
|
|
||||||
className={`${buttonBase} bg-white/15 text-error-primary hover:bg-white/25`}
|
|
||||||
>
|
|
||||||
Mark Spam
|
Mark Spam
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={onDeselect} className={`${buttonBase} bg-white/10 text-white/70 hover:bg-white/20 hover:text-white`}>
|
||||||
type="button"
|
|
||||||
onClick={onDeselect}
|
|
||||||
className={`${buttonBase} bg-white/10 text-white/70 hover:bg-white/20 hover:text-white`}
|
|
||||||
>
|
|
||||||
Deselect
|
Deselect
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
type FilterPill = {
|
type FilterPill = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -23,7 +23,7 @@ export const FilterPills = ({ filters, onRemove, onClearAll }: FilterPillsProps)
|
|||||||
<span
|
<span
|
||||||
key={filter.key}
|
key={filter.key}
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex items-center gap-1 rounded-full border border-brand bg-brand-primary px-3 py-1 text-xs font-medium text-brand-secondary',
|
"flex items-center gap-1 rounded-full border border-brand bg-brand-primary px-3 py-1 text-xs font-medium text-brand-secondary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{filter.label}: {filter.value}
|
{filter.label}: {filter.value}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { cx } from '@/utils/cx';
|
import { formatShortDate } from "@/lib/format";
|
||||||
import { formatShortDate } from '@/lib/format';
|
import type { FollowUp } from "@/types/entities";
|
||||||
import type { FollowUp } from '@/types/entities';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface FollowupWidgetProps {
|
interface FollowupWidgetProps {
|
||||||
overdue: FollowUp[];
|
overdue: FollowUp[];
|
||||||
@@ -26,26 +26,15 @@ export const FollowupWidget = ({ overdue, upcoming }: FollowupWidgetProps) => {
|
|||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={cx(
|
className={cx(
|
||||||
'rounded-lg border-l-2 p-3',
|
"rounded-lg border-l-2 p-3",
|
||||||
isOverdue
|
isOverdue ? "border-l-error-solid bg-error-primary" : "border-l-brand-solid bg-secondary",
|
||||||
? 'border-l-error-solid bg-error-primary'
|
|
||||||
: 'border-l-brand-solid bg-secondary',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p
|
<p className={cx("text-xs font-semibold", isOverdue ? "text-error-primary" : "text-brand-secondary")}>
|
||||||
className={cx(
|
{item.scheduledAt ? formatShortDate(item.scheduledAt) : "No date"}
|
||||||
'text-xs font-semibold',
|
{isOverdue && " — Overdue"}
|
||||||
isOverdue ? 'text-error-primary' : 'text-brand-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.scheduledAt
|
|
||||||
? formatShortDate(item.scheduledAt)
|
|
||||||
: 'No date'}
|
|
||||||
{isOverdue && ' — Overdue'}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-xs font-medium text-primary">
|
|
||||||
{item.description ?? item.followUpType ?? 'Follow-up'}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs font-medium text-primary">{item.description ?? item.followUpType ?? "Follow-up"}</p>
|
||||||
{(item.patientName || item.patientPhone) && (
|
{(item.patientName || item.patientPhone) && (
|
||||||
<p className="mt-0.5 text-xs text-tertiary">
|
<p className="mt-0.5 text-xs text-tertiary">
|
||||||
{item.patientName}
|
{item.patientName}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cx } from '@/utils/cx';
|
import type { Lead } from "@/types/entities";
|
||||||
import type { Lead } from '@/types/entities';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface KpiCardsProps {
|
interface KpiCardsProps {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
@@ -14,38 +14,38 @@ type KpiCard = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const KpiCards = ({ leads }: KpiCardsProps) => {
|
export const KpiCards = ({ leads }: KpiCardsProps) => {
|
||||||
const newCount = leads.filter((l) => l.leadStatus === 'NEW').length;
|
const newCount = leads.filter((l) => l.leadStatus === "NEW").length;
|
||||||
const assignedCount = leads.filter((l) => l.assignedAgent !== null).length;
|
const assignedCount = leads.filter((l) => l.assignedAgent !== null).length;
|
||||||
const contactedCount = leads.filter((l) => l.leadStatus === 'CONTACTED').length;
|
const contactedCount = leads.filter((l) => l.leadStatus === "CONTACTED").length;
|
||||||
const convertedCount = leads.filter((l) => l.leadStatus === 'CONVERTED').length;
|
const convertedCount = leads.filter((l) => l.leadStatus === "CONVERTED").length;
|
||||||
|
|
||||||
const cards: KpiCard[] = [
|
const cards: KpiCard[] = [
|
||||||
{
|
{
|
||||||
label: 'New Leads Today',
|
label: "New Leads Today",
|
||||||
value: newCount,
|
value: newCount,
|
||||||
delta: '+12% vs yesterday',
|
delta: "+12% vs yesterday",
|
||||||
deltaColor: 'text-success-primary',
|
deltaColor: "text-success-primary",
|
||||||
isHero: true,
|
isHero: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Assigned to CC',
|
label: "Assigned to CC",
|
||||||
value: assignedCount,
|
value: assignedCount,
|
||||||
delta: '85% assigned',
|
delta: "85% assigned",
|
||||||
deltaColor: 'text-brand-secondary',
|
deltaColor: "text-brand-secondary",
|
||||||
isHero: false,
|
isHero: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Contacted',
|
label: "Contacted",
|
||||||
value: contactedCount,
|
value: contactedCount,
|
||||||
delta: '+8% vs yesterday',
|
delta: "+8% vs yesterday",
|
||||||
deltaColor: 'text-success-primary',
|
deltaColor: "text-success-primary",
|
||||||
isHero: false,
|
isHero: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Converted',
|
label: "Converted",
|
||||||
value: convertedCount,
|
value: convertedCount,
|
||||||
delta: '+3 this week',
|
delta: "+3 this week",
|
||||||
deltaColor: 'text-warning-primary',
|
deltaColor: "text-warning-primary",
|
||||||
isHero: false,
|
isHero: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -56,36 +56,13 @@ export const KpiCards = ({ leads }: KpiCardsProps) => {
|
|||||||
<div
|
<div
|
||||||
key={card.label}
|
key={card.label}
|
||||||
className={cx(
|
className={cx(
|
||||||
'rounded-2xl p-5 transition hover:shadow-md',
|
"rounded-2xl p-5 transition hover:shadow-md",
|
||||||
card.isHero
|
card.isHero ? "flex-[1.3] bg-brand-solid text-white" : "flex-1 border border-secondary bg-primary",
|
||||||
? 'flex-[1.3] bg-brand-solid text-white'
|
|
||||||
: 'flex-1 border border-secondary bg-primary',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p
|
<p className={cx("text-xs font-medium tracking-wider uppercase", card.isHero ? "text-white/70" : "text-quaternary")}>{card.label}</p>
|
||||||
className={cx(
|
<p className={cx("mt-1 text-display-sm font-bold", card.isHero ? "text-white" : "text-primary")}>{card.value}</p>
|
||||||
'text-xs font-medium uppercase tracking-wider',
|
<p className={cx("mt-1 text-xs", card.isHero ? "text-white/80" : card.deltaColor)}>{card.delta}</p>
|
||||||
card.isHero ? 'text-white/70' : 'text-quaternary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{card.label}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={cx(
|
|
||||||
'mt-1 text-display-sm font-bold',
|
|
||||||
card.isHero ? 'text-white' : 'text-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{card.value}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={cx(
|
|
||||||
'mt-1 text-xs',
|
|
||||||
card.isHero ? 'text-white/80' : card.deltaColor,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{card.delta}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
import { SlideoutMenu } from "@/components/application/slideout-menus/slideout-menu";
|
||||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
import { formatPhone, formatShortDate } from "@/lib/format";
|
||||||
import type { Lead, LeadActivity, LeadActivityType } from '@/types/entities';
|
import type { Lead, LeadActivity, LeadActivityType } from "@/types/entities";
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
type LeadActivitySlideoutProps = {
|
type LeadActivitySlideoutProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -17,31 +17,29 @@ type ActivityConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ACTIVITY_CONFIG: Record<LeadActivityType, ActivityConfig> = {
|
const ACTIVITY_CONFIG: Record<LeadActivityType, ActivityConfig> = {
|
||||||
STATUS_CHANGE: { icon: '🔄', dotClass: 'bg-brand-secondary', label: 'Status Changed' },
|
STATUS_CHANGE: { icon: "🔄", dotClass: "bg-brand-secondary", label: "Status Changed" },
|
||||||
CALL_MADE: { icon: '📞', dotClass: 'bg-brand-secondary', label: 'Call Made' },
|
CALL_MADE: { icon: "📞", dotClass: "bg-brand-secondary", label: "Call Made" },
|
||||||
CALL_RECEIVED: { icon: '📲', dotClass: 'bg-brand-secondary', label: 'Call Received' },
|
CALL_RECEIVED: { icon: "📲", dotClass: "bg-brand-secondary", label: "Call Received" },
|
||||||
WHATSAPP_SENT: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Sent' },
|
WHATSAPP_SENT: { icon: "💬", dotClass: "bg-success-solid", label: "WhatsApp Sent" },
|
||||||
WHATSAPP_RECEIVED: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Received' },
|
WHATSAPP_RECEIVED: { icon: "💬", dotClass: "bg-success-solid", label: "WhatsApp Received" },
|
||||||
SMS_SENT: { icon: '✉️', dotClass: 'bg-brand-secondary', label: 'SMS Sent' },
|
SMS_SENT: { icon: "✉️", dotClass: "bg-brand-secondary", label: "SMS Sent" },
|
||||||
EMAIL_SENT: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Sent' },
|
EMAIL_SENT: { icon: "📧", dotClass: "bg-brand-secondary", label: "Email Sent" },
|
||||||
EMAIL_RECEIVED: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Received' },
|
EMAIL_RECEIVED: { icon: "📧", dotClass: "bg-brand-secondary", label: "Email Received" },
|
||||||
NOTE_ADDED: { icon: '📝', dotClass: 'bg-warning-solid', label: 'Note Added' },
|
NOTE_ADDED: { icon: "📝", dotClass: "bg-warning-solid", label: "Note Added" },
|
||||||
ASSIGNED: { icon: '📤', dotClass: 'bg-brand-secondary', label: 'Assigned' },
|
ASSIGNED: { icon: "📤", dotClass: "bg-brand-secondary", label: "Assigned" },
|
||||||
APPOINTMENT_BOOKED: { icon: '📅', dotClass: 'bg-brand-secondary', label: 'Appointment Booked' },
|
APPOINTMENT_BOOKED: { icon: "📅", dotClass: "bg-brand-secondary", label: "Appointment Booked" },
|
||||||
FOLLOW_UP_CREATED: { icon: '🔁', dotClass: 'bg-brand-secondary', label: 'Follow-up Created' },
|
FOLLOW_UP_CREATED: { icon: "🔁", dotClass: "bg-brand-secondary", label: "Follow-up Created" },
|
||||||
CONVERTED: { icon: '✅', dotClass: 'bg-success-solid', label: 'Converted' },
|
CONVERTED: { icon: "✅", dotClass: "bg-success-solid", label: "Converted" },
|
||||||
MARKED_SPAM: { icon: '🚫', dotClass: 'bg-error-solid', label: 'Marked as Spam' },
|
MARKED_SPAM: { icon: "🚫", dotClass: "bg-error-solid", label: "Marked as Spam" },
|
||||||
DUPLICATE_DETECTED: { icon: '🔍', dotClass: 'bg-warning-solid', label: 'Duplicate Detected' },
|
DUPLICATE_DETECTED: { icon: "🔍", dotClass: "bg-warning-solid", label: "Duplicate Detected" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary', label: 'Activity' };
|
const DEFAULT_CONFIG: ActivityConfig = { icon: "📌", dotClass: "bg-tertiary", label: "Activity" };
|
||||||
|
|
||||||
const StatusChangeContent = ({ previousValue, newValue }: { previousValue: string | null; newValue: string | null }) => (
|
const StatusChangeContent = ({ previousValue, newValue }: { previousValue: string | null; newValue: string | null }) => (
|
||||||
<span className="text-sm text-secondary">
|
<span className="text-sm text-secondary">
|
||||||
{previousValue && (
|
{previousValue && <span className="mr-1 text-sm text-quaternary line-through">{previousValue}</span>}
|
||||||
<span className="mr-1 text-sm line-through text-quaternary">{previousValue}</span>
|
{previousValue && newValue && "→ "}
|
||||||
)}
|
|
||||||
{previousValue && newValue && '→ '}
|
|
||||||
{newValue && <span className="font-medium text-brand-secondary">{newValue}</span>}
|
{newValue && <span className="font-medium text-brand-secondary">{newValue}</span>}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -49,45 +47,33 @@ const StatusChangeContent = ({ previousValue, newValue }: { previousValue: strin
|
|||||||
const ActivityItem = ({ activity, isLast }: { activity: LeadActivity; isLast: boolean }) => {
|
const ActivityItem = ({ activity, isLast }: { activity: LeadActivity; isLast: boolean }) => {
|
||||||
const type = activity.activityType;
|
const type = activity.activityType;
|
||||||
const config = type ? (ACTIVITY_CONFIG[type] ?? DEFAULT_CONFIG) : DEFAULT_CONFIG;
|
const config = type ? (ACTIVITY_CONFIG[type] ?? DEFAULT_CONFIG) : DEFAULT_CONFIG;
|
||||||
const occurredAt = activity.occurredAt ? formatShortDate(activity.occurredAt) : '';
|
const occurredAt = activity.occurredAt ? formatShortDate(activity.occurredAt) : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex gap-3 pb-4">
|
<div className="relative flex gap-3 pb-4">
|
||||||
{/* Vertical connecting line */}
|
{/* Vertical connecting line */}
|
||||||
{!isLast && (
|
{!isLast && <div className="absolute top-[36px] bottom-0 left-[15px] w-0.5 bg-tertiary" />}
|
||||||
<div className="absolute left-[15px] top-[36px] bottom-0 w-0.5 bg-tertiary" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dot */}
|
{/* Dot */}
|
||||||
<div
|
<div className={cx("relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full text-sm", config.dotClass)} aria-hidden="true">
|
||||||
className={cx(
|
|
||||||
'relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full text-sm',
|
|
||||||
config.dotClass,
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{config.icon}
|
{config.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex flex-1 flex-col gap-0.5 pt-1 min-w-0">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5 pt-1">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className="flex-1 text-sm font-semibold text-primary">
|
<span className="flex-1 text-sm font-semibold text-primary">{activity.summary ?? config.label}</span>
|
||||||
{activity.summary ?? config.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{type === 'STATUS_CHANGE' && (activity.previousValue || activity.newValue) && (
|
{type === "STATUS_CHANGE" && (activity.previousValue || activity.newValue) && (
|
||||||
<StatusChangeContent previousValue={activity.previousValue} newValue={activity.newValue} />
|
<StatusChangeContent previousValue={activity.previousValue} newValue={activity.newValue} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type !== 'STATUS_CHANGE' && activity.newValue && (
|
{type !== "STATUS_CHANGE" && activity.newValue && <p className="text-xs text-tertiary">{activity.newValue}</p>}
|
||||||
<p className="text-xs text-tertiary">{activity.newValue}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-quaternary">
|
<p className="text-xs text-quaternary">
|
||||||
{occurredAt}
|
{occurredAt}
|
||||||
{activity.performedBy ? ` · by ${activity.performedBy}` : ''}
|
{activity.performedBy ? ` · by ${activity.performedBy}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,9 +81,9 @@ const ActivityItem = ({ activity, isLast }: { activity: LeadActivity; isLast: bo
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }: LeadActivitySlideoutProps) => {
|
export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }: LeadActivitySlideoutProps) => {
|
||||||
const firstName = lead.contactName?.firstName ?? '';
|
const firstName = lead.contactName?.firstName ?? "";
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
const lastName = lead.contactName?.lastName ?? "";
|
||||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
|
const fullName = `${firstName} ${lastName}`.trim() || "Unknown Lead";
|
||||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : null;
|
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : null;
|
||||||
const email = lead.contactEmail?.[0]?.address ?? null;
|
const email = lead.contactEmail?.[0]?.address ?? null;
|
||||||
|
|
||||||
@@ -116,11 +102,7 @@ export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }:
|
|||||||
<SlideoutMenu.Header onClose={close}>
|
<SlideoutMenu.Header onClose={close}>
|
||||||
<div className="flex flex-col gap-0.5 pr-8">
|
<div className="flex flex-col gap-0.5 pr-8">
|
||||||
<h2 className="text-lg font-semibold text-primary">Lead Activity — {fullName}</h2>
|
<h2 className="text-lg font-semibold text-primary">Lead Activity — {fullName}</h2>
|
||||||
<p className="text-sm text-tertiary">
|
<p className="text-sm text-tertiary">{[phone, email, lead.leadSource, lead.utmCampaign].filter(Boolean).join(" · ")}</p>
|
||||||
{[phone, email, lead.leadSource, lead.utmCampaign]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</SlideoutMenu.Header>
|
</SlideoutMenu.Header>
|
||||||
|
|
||||||
@@ -134,11 +116,7 @@ export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }:
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{filteredActivities.map((activity, idx) => (
|
{filteredActivities.map((activity, idx) => (
|
||||||
<ActivityItem
|
<ActivityItem key={activity.id} activity={activity} isLast={idx === filteredActivities.length - 1} />
|
||||||
key={activity.id}
|
|
||||||
activity={activity}
|
|
||||||
isLast={idx === filteredActivities.length - 1}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Avatar } from '@/components/base/avatar/avatar';
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { LeadStatusBadge } from '@/components/shared/status-badge';
|
import { AgeIndicator } from "@/components/shared/age-indicator";
|
||||||
import { SourceTag } from '@/components/shared/source-tag';
|
import { SourceTag } from "@/components/shared/source-tag";
|
||||||
import { AgeIndicator } from '@/components/shared/age-indicator';
|
import { LeadStatusBadge } from "@/components/shared/status-badge";
|
||||||
import { formatPhone, getInitials } from '@/lib/format';
|
import { formatPhone, getInitials } from "@/lib/format";
|
||||||
import { cx } from '@/utils/cx';
|
import type { Lead } from "@/types/entities";
|
||||||
import type { Lead } from '@/types/entities';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface LeadCardProps {
|
interface LeadCardProps {
|
||||||
lead: Lead;
|
lead: Lead;
|
||||||
@@ -19,34 +19,34 @@ interface LeadCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sourceLabelMap: Record<string, string> = {
|
const sourceLabelMap: Record<string, string> = {
|
||||||
FACEBOOK_AD: 'Facebook',
|
FACEBOOK_AD: "Facebook",
|
||||||
INSTAGRAM: 'Instagram',
|
INSTAGRAM: "Instagram",
|
||||||
GOOGLE_AD: 'Google',
|
GOOGLE_AD: "Google",
|
||||||
GOOGLE_MY_BUSINESS: 'GMB',
|
GOOGLE_MY_BUSINESS: "GMB",
|
||||||
WHATSAPP: 'WhatsApp',
|
WHATSAPP: "WhatsApp",
|
||||||
WEBSITE: 'Website',
|
WEBSITE: "Website",
|
||||||
REFERRAL: 'Referral',
|
REFERRAL: "Referral",
|
||||||
WALK_IN: 'Walk-in',
|
WALK_IN: "Walk-in",
|
||||||
PHONE: 'Phone',
|
PHONE: "Phone",
|
||||||
OTHER: 'Other',
|
OTHER: "Other",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LeadCard = ({ lead, onAssign, onMessage, onMarkSpam, onMerge, onLogCall, onUpdateStatus }: LeadCardProps) => {
|
export const LeadCard = ({ lead, onAssign, onMessage, onMarkSpam, onMerge, onLogCall, onUpdateStatus }: LeadCardProps) => {
|
||||||
const firstName = lead.contactName?.firstName ?? '';
|
const firstName = lead.contactName?.firstName ?? "";
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
const lastName = lead.contactName?.lastName ?? "";
|
||||||
const name = `${firstName} ${lastName}`.trim() || 'Unknown';
|
const name = `${firstName} ${lastName}`.trim() || "Unknown";
|
||||||
const initials = firstName && lastName ? getInitials(firstName, lastName) : '??';
|
const initials = firstName && lastName ? getInitials(firstName, lastName) : "??";
|
||||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : '';
|
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "";
|
||||||
const sourceLabel = lead.leadSource ? sourceLabelMap[lead.leadSource] ?? lead.leadSource : '';
|
const sourceLabel = lead.leadSource ? (sourceLabelMap[lead.leadSource] ?? lead.leadSource) : "";
|
||||||
const isSpam = (lead.spamScore ?? 0) >= 60;
|
const isSpam = (lead.spamScore ?? 0) >= 60;
|
||||||
const isDuplicate = lead.isDuplicate === true;
|
const isDuplicate = lead.isDuplicate === true;
|
||||||
const isAssigned = lead.assignedAgent !== null && lead.leadStatus !== 'NEW';
|
const isAssigned = lead.assignedAgent !== null && lead.leadStatus !== "NEW";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex items-center gap-4 rounded-2xl border border-secondary p-5 transition hover:shadow-md',
|
"flex items-center gap-4 rounded-2xl border border-secondary p-5 transition hover:shadow-md",
|
||||||
isSpam ? 'bg-warning-primary' : 'bg-primary',
|
isSpam ? "bg-warning-primary" : "bg-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cx } from '@/utils/cx';
|
import type { Lead, LeadSource } from "@/types/entities";
|
||||||
import type { Lead, LeadSource } from '@/types/entities';
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
interface SourceGridProps {
|
interface SourceGridProps {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
@@ -19,64 +19,63 @@ type SourceConfig = {
|
|||||||
|
|
||||||
const sourceConfigs: SourceConfig[] = [
|
const sourceConfigs: SourceConfig[] = [
|
||||||
{
|
{
|
||||||
source: 'FACEBOOK_AD',
|
source: "FACEBOOK_AD",
|
||||||
label: 'Facebook Ads',
|
label: "Facebook Ads",
|
||||||
icon: 'f',
|
icon: "f",
|
||||||
iconBg: 'bg-utility-blue-50',
|
iconBg: "bg-utility-blue-50",
|
||||||
iconText: 'text-utility-blue-700',
|
iconText: "text-utility-blue-700",
|
||||||
countColor: 'text-utility-blue-700',
|
countColor: "text-utility-blue-700",
|
||||||
delta: '+4',
|
delta: "+4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: 'GOOGLE_AD',
|
source: "GOOGLE_AD",
|
||||||
label: 'Google Ads',
|
label: "Google Ads",
|
||||||
icon: 'G',
|
icon: "G",
|
||||||
iconBg: 'bg-utility-success-50',
|
iconBg: "bg-utility-success-50",
|
||||||
iconText: 'text-utility-success-700',
|
iconText: "text-utility-success-700",
|
||||||
countColor: 'text-utility-success-700',
|
countColor: "text-utility-success-700",
|
||||||
delta: '+2',
|
delta: "+2",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: 'INSTAGRAM',
|
source: "INSTAGRAM",
|
||||||
label: 'Instagram',
|
label: "Instagram",
|
||||||
icon: '@',
|
icon: "@",
|
||||||
iconBg: 'bg-utility-pink-50',
|
iconBg: "bg-utility-pink-50",
|
||||||
iconText: 'text-utility-pink-700',
|
iconText: "text-utility-pink-700",
|
||||||
countColor: 'text-utility-pink-700',
|
countColor: "text-utility-pink-700",
|
||||||
delta: '+1',
|
delta: "+1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: 'GOOGLE_MY_BUSINESS',
|
source: "GOOGLE_MY_BUSINESS",
|
||||||
label: 'Google My Business',
|
label: "Google My Business",
|
||||||
icon: 'G',
|
icon: "G",
|
||||||
iconBg: 'bg-utility-blue-light-50',
|
iconBg: "bg-utility-blue-light-50",
|
||||||
iconText: 'text-utility-blue-light-700',
|
iconText: "text-utility-blue-light-700",
|
||||||
countColor: 'text-utility-blue-light-700',
|
countColor: "text-utility-blue-light-700",
|
||||||
delta: '+3',
|
delta: "+3",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: 'REFERRAL',
|
source: "REFERRAL",
|
||||||
label: 'Referrals',
|
label: "Referrals",
|
||||||
icon: 'R',
|
icon: "R",
|
||||||
iconBg: 'bg-utility-purple-50',
|
iconBg: "bg-utility-purple-50",
|
||||||
iconText: 'text-utility-purple-700',
|
iconText: "text-utility-purple-700",
|
||||||
countColor: 'text-utility-purple-700',
|
countColor: "text-utility-purple-700",
|
||||||
delta: '+2',
|
delta: "+2",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: 'WALK_IN',
|
source: "WALK_IN",
|
||||||
label: 'Walk-ins',
|
label: "Walk-ins",
|
||||||
icon: 'W',
|
icon: "W",
|
||||||
iconBg: 'bg-utility-orange-50',
|
iconBg: "bg-utility-orange-50",
|
||||||
iconText: 'text-utility-orange-700',
|
iconText: "text-utility-orange-700",
|
||||||
countColor: 'text-utility-orange-700',
|
countColor: "text-utility-orange-700",
|
||||||
delta: '0',
|
delta: "0",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SourceGrid = ({ leads, onSourceFilter, activeSource }: SourceGridProps) => {
|
export const SourceGrid = ({ leads, onSourceFilter, activeSource }: SourceGridProps) => {
|
||||||
const countBySource = (source: LeadSource): number =>
|
const countBySource = (source: LeadSource): number => leads.filter((l) => l.leadSource === source).length;
|
||||||
leads.filter((l) => l.leadSource === source).length;
|
|
||||||
|
|
||||||
const handleClick = (source: LeadSource) => {
|
const handleClick = (source: LeadSource) => {
|
||||||
if (activeSource === source) {
|
if (activeSource === source) {
|
||||||
@@ -98,37 +97,25 @@ export const SourceGrid = ({ leads, onSourceFilter, activeSource }: SourceGridPr
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleClick(config.source)}
|
onClick={() => handleClick(config.source)}
|
||||||
className={cx(
|
className={cx(
|
||||||
'cursor-pointer rounded-xl border border-secondary bg-primary p-4 text-left transition hover:shadow-md',
|
"cursor-pointer rounded-xl border border-secondary bg-primary p-4 text-left transition hover:shadow-md",
|
||||||
isActive && 'ring-2 ring-brand',
|
isActive && "ring-2 ring-brand",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span
|
<span className={cx("flex size-6 items-center justify-center rounded text-xs font-bold", config.iconBg, config.iconText)}>
|
||||||
className={cx(
|
|
||||||
'flex size-6 items-center justify-center rounded text-xs font-bold',
|
|
||||||
config.iconBg,
|
|
||||||
config.iconText,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{config.icon}
|
{config.icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-quaternary">{config.label}</span>
|
<span className="text-xs text-quaternary">{config.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className={cx('text-xl font-bold', config.countColor)}>
|
<span className={cx("text-xl font-bold", config.countColor)}>{count}</span>
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
className={cx(
|
className={cx(
|
||||||
'text-xs',
|
"text-xs",
|
||||||
config.delta.startsWith('+')
|
config.delta.startsWith("+") ? "text-success-primary" : config.delta === "0" ? "text-quaternary" : "text-error-primary",
|
||||||
? 'text-success-primary'
|
|
||||||
: config.delta === '0'
|
|
||||||
? 'text-quaternary'
|
|
||||||
: 'text-error-primary',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{config.delta === '0' ? 'same' : config.delta}
|
{config.delta === "0" ? "same" : config.delta}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { faBookBookmark, faCirclePlay, faFileCode, faLifeRing, faStars } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faBookBookmark, faFileCode, faLifeRing, faCirclePlay, faStars } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { NavMenuItemLink } from "./base-components/nav-menu-item";
|
import { NavMenuItemLink } from "./base-components/nav-menu-item";
|
||||||
|
|
||||||
const BookClosed: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBookBookmark} className={className} />;
|
const BookClosed: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBookBookmark} className={className} />;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import { type ReactNode, useRef, useState } from "react";
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||||
@@ -130,7 +129,10 @@ export const Header = ({ items = headerNavItems, isFullWidth, isFloating, classN
|
|||||||
<AriaButton className="flex cursor-pointer items-center gap-0.5 rounded-lg px-1.5 py-1 text-md font-semibold text-secondary outline-focus-ring transition duration-100 ease-linear hover:text-secondary_hover focus-visible:outline-2 focus-visible:outline-offset-2">
|
<AriaButton className="flex cursor-pointer items-center gap-0.5 rounded-lg px-1.5 py-1 text-md font-semibold text-secondary outline-focus-ring transition duration-100 ease-linear hover:text-secondary_hover focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||||
<span className="px-0.5">{navItem.label}</span>
|
<span className="px-0.5">{navItem.label}</span>
|
||||||
|
|
||||||
<FontAwesomeIcon icon={faChevronDown} className="size-4 rotate-0 text-fg-quaternary transition duration-100 ease-linear in-aria-expanded:-rotate-180" />
|
<FontAwesomeIcon
|
||||||
|
icon={faChevronDown}
|
||||||
|
className="size-4 rotate-0 text-fg-quaternary transition duration-100 ease-linear in-aria-expanded:-rotate-180"
|
||||||
|
/>
|
||||||
</AriaButton>
|
</AriaButton>
|
||||||
|
|
||||||
<AriaPopover
|
<AriaPopover
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user