7 Commits

Author SHA1 Message Date
Kartik Datrika
0570a274ad Merge branch 'dev-main' into dev-kartik 2026-04-06 10:48:09 +05:30
Kartik Datrika
ea5d8ed89a format 2026-03-25 15:21:35 +05:30
Kartik Datrika
698bdf488a merge conflicts resolved. 2026-03-25 12:15:37 +05:30
Kartik Datrika
dc59189cc6 no duplicate imports 2026-03-25 11:48:12 +05:30
Kartik Datrika
c3fb1f0cf3 Merge branch 'dev-main' into dev-kartik 2026-03-25 11:48:00 +05:30
Kartik Datrika
95d4009214 Merge branch 'dev' into dev-kartik 2026-03-24 15:41:25 +05:30
Kartik Datrika
2c87a39733 Linting and Formatting 2026-03-23 16:41:58 +05:30
164 changed files with 8195 additions and 3438 deletions

38
.claudeignore Normal file
View 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
View File

@@ -0,0 +1 @@
npx lint-staged

View File

@@ -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$",
@@ -16,4 +19,4 @@
"importOrderSeparation": false, "importOrderSeparation": false,
"importOrderSortSpecifiers": true, "importOrderSortSpecifiers": true,
"tailwindStylesheet": "./src/styles/globals.css" "tailwindStylesheet": "./src/styles/globals.css"
} }

View File

@@ -39,15 +39,16 @@ 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` |
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` | | `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
| `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
View 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

File diff suppressed because it is too large Load Diff

View 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" : ""}.`);

View File

@@ -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>

View File

@@ -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>
)} )}

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>();

View File

@@ -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 {

View File

@@ -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 (

View File

@@ -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> {

View File

@@ -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())];

View File

@@ -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>();

View File

@@ -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";
} }

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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";

View File

@@ -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) {

View File

@@ -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. */

View File

@@ -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. */

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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) { ...otherProps,
props = { href: isDisabled ? undefined : href,
...otherProps, // 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
href: isDisabled ? undefined : href, // to use the `disabled:` selector in classes.
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
// 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 : {
// to use the `disabled:` selector in classes. ...otherProps,
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}), type: otherProps.type || "button",
}; isDisabled,
} else { };
props = {
...otherProps,
type: otherProps.type || "button",
isDisabled,
};
}
const content = ( const content = (
<Component <Component

View File

@@ -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) { ...otherProps,
props = { href: disabled ? undefined : href,
...otherProps, }
: {
href: disabled ? undefined : href, ...otherProps,
}; type: otherProps.type || "button",
} else { isPending: loading,
props = { };
...otherProps,
type: otherProps.type || "button",
isPending: loading,
};
}
return ( return (
<Component <Component

View File

@@ -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";

View File

@@ -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) { ...otherProps,
props = { href: disabled ? undefined : href,
...otherProps, // 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
href: disabled ? undefined : href, // to use the `disabled:` selector in classes.
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
// 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 : {
// to use the `disabled:` selector in classes. ...otherProps,
...(disabled ? { "data-rac": true, "data-disabled": true } : {}), type: otherProps.type || "button",
}; isDisabled: disabled,
} else { };
props = {
...otherProps,
type: otherProps.type || "button",
isDisabled: disabled,
};
}
return ( return (
<Component <Component

View File

@@ -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

View File

@@ -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}
/> />
</> </>

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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) => {

View File

@@ -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>;
} }

View File

@@ -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";

View File

@@ -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);

View File

@@ -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";

View File

@@ -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]);

View File

@@ -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> {

View File

@@ -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>

View File

@@ -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";

View File

@@ -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) => {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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 (

View File

@@ -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>
); );
})} })}

View File

@@ -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>

View File

@@ -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>
); );
}; };

View File

@@ -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>
); );
}; };

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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}

View File

@@ -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 */}

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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>
); );
})} })}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
); );
})} })}

View File

@@ -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 total = agentCalls.length; const missed = agentCalls.filter((c) => c.callStatus === "MISSED").length;
const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0); const total = agentCalls.length;
const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0); const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0);
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0; const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length; const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
const conversion = total > 0 ? (booked / total) * 100 : 0; const booked = agentCalls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length;
const nameParts = name.split(' '); const conversion = total > 0 ? (booked / total) * 100 : 0;
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,
}).sort((a, b) => b.total - a.total); missed,
total,
avgHandle,
conversion,
};
})
.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>

View File

@@ -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
const diff = Math.abs(new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000; ? Math.round(
return sum + diff; leadsWithResponse.reduce((sum, l) => {
}, 0) / leadsWithResponse.length) const diff = Math.abs(new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000;
: null; return sum + diff;
}, 0) / leadsWithResponse.length,
)
: 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();
: null; const missedCallbackTime =
missedCallsList.length > 0
? Math.round(missedCallsList.reduce((sum, c) => sum + (renderTime - new Date(c.startedAt!).getTime()) / 60000, 0) / missedCallsList.length)
: 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>
); );

View File

@@ -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>

View File

@@ -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";

View File

@@ -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>) => {

View File

@@ -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);

View File

@@ -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>
)} )}

View File

@@ -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();

View File

@@ -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 />;

View File

@@ -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>

View File

@@ -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 &gt; 5 days</p>
{agingCount} leads aging &gt; 5 days <p className="mt-1 text-xs text-error-primary opacity-80">These leads haven&apos;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&apos;t been contacted and are at risk of going cold.
</p>
</div> </div>
); );
}; };

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>
)} )}

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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} />;

View File

@@ -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