mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
Compare commits
7 Commits
1e4fa41a97
...
dev-kartik
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0570a274ad | ||
|
|
ea5d8ed89a | ||
|
|
698bdf488a | ||
|
|
dc59189cc6 | ||
|
|
c3fb1f0cf3 | ||
|
|
95d4009214 | ||
|
|
2c87a39733 |
38
.claudeignore
Normal file
38
.claudeignore
Normal file
@@ -0,0 +1,38 @@
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
.vite/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Lock files (large, rarely useful)
|
||||
package-lock.json
|
||||
bun.lock
|
||||
yarn.lock
|
||||
|
||||
# Generated / cache
|
||||
nanobanana-output/
|
||||
*.tsbuildinfo
|
||||
.cache/
|
||||
|
||||
# Design / static assets
|
||||
public/
|
||||
src/components/shared-assets/
|
||||
|
||||
# Type declaration outputs
|
||||
**/*.d.ts
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# GitHub workflows (not relevant to code tasks)
|
||||
.github/
|
||||
|
||||
# Scripts (deployment/utility scripts rarely needed)
|
||||
scripts/
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -5,7 +5,10 @@
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"tailwindFunctions": ["sortCx", "cx"],
|
||||
"tailwindFunctions": [
|
||||
"sortCx",
|
||||
"cx"
|
||||
],
|
||||
"importOrder": [
|
||||
"^react$",
|
||||
"^react-dom$",
|
||||
@@ -16,4 +19,4 @@
|
||||
"importOrderSeparation": false,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"tailwindStylesheet": "./src/styles/globals.css"
|
||||
}
|
||||
}
|
||||
24
README.md
24
README.md
@@ -39,15 +39,16 @@ npm run build # TypeScript check + production build
|
||||
|
||||
### Environment Variables (set at build time or in `.env`)
|
||||
|
||||
| Variable | Purpose | Dev Default | Production |
|
||||
|----------|---------|-------------|------------|
|
||||
| `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_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
|
||||
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
|
||||
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
|
||||
| Variable | Purpose | Dev Default | Production |
|
||||
| -------------------- | ---------------- | ----------------------- | ------------------------------------------- |
|
||||
| `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_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
|
||||
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
|
||||
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
|
||||
|
||||
**Production build command:**
|
||||
|
||||
```bash
|
||||
VITE_API_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
|
||||
|
||||
### "The call desk isn't working"
|
||||
|
||||
**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.
|
||||
|
||||
### "Calls aren't connecting / SIP errors"
|
||||
|
||||
**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.
|
||||
|
||||
### "Worklist not loading / empty"
|
||||
|
||||
**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.
|
||||
|
||||
### "Missed calls not appearing / sub-tabs empty"
|
||||
|
||||
**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`).
|
||||
|
||||
### "Disposition / appointment not saving"
|
||||
|
||||
**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.
|
||||
|
||||
### "Login broken / Failed to fetch"
|
||||
|
||||
**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.**
|
||||
|
||||
### "UI component looks wrong"
|
||||
|
||||
**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)`.
|
||||
|
||||
### "Navigation / role-based access"
|
||||
|
||||
**File:** `src/components/layout/sidebar.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:
|
||||
|
||||
1. **Sidecar** (REST) — for Ozonetel telephony operations and worklist
|
||||
2. **Platform** (GraphQL) — for entity CRUD (leads, appointments, patients)
|
||||
|
||||
|
||||
41
eslint.config.mjs
Normal file
41
eslint.config.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'node_modules'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
// React Hooks — enforce rules of hooks and exhaustive deps
|
||||
...reactHooks.configs.recommended.rules,
|
||||
|
||||
// React Refresh — warn on non-component exports (Vite HMR)
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
|
||||
// TypeScript
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||
|
||||
// General
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
'no-duplicate-imports': 'error',
|
||||
},
|
||||
}
|
||||
);
|
||||
4641
pnpm-lock.yaml
generated
Normal file
4641
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
227
scripts/fix-duplicate-imports.mjs
Normal file
227
scripts/fix-duplicate-imports.mjs
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* fix-duplicate-imports.mjs
|
||||
*
|
||||
* Merges duplicate import statements from the same module across all TypeScript
|
||||
* source files in the project. Run this whenever `npm run lint` reports
|
||||
* `no-duplicate-imports` errors.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/fix-duplicate-imports.mjs
|
||||
*
|
||||
* Handles:
|
||||
* import type { A, B } from 'module' — type-only imports
|
||||
* import Default from 'module' — default imports
|
||||
* import Default, { A, B } from 'mod' — mixed default + named
|
||||
* import { A, B } from 'module' — named imports
|
||||
* import {\n A,\n B\n} from 'mod' — multi-line named imports
|
||||
*
|
||||
* When merging a `import type` with a value import from the same module,
|
||||
* type-only specifiers are inlined as `type Name` in the merged statement.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
||||
import { extname, join, resolve } from "path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const projectRoot = resolve(new URL(".", import.meta.url).pathname, "..");
|
||||
const srcDir = join(projectRoot, "src");
|
||||
const extensions = new Set([".ts", ".tsx"]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File discovery — recursively find all TS/TSX files under src/
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findFiles(dir) {
|
||||
const results = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
results.push(...findFiles(full));
|
||||
} else if (extensions.has(extname(entry))) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extracts all top-level import statements from file content.
|
||||
* Returns array of { start, end, raw, module, isType, defaultImport, namedImports }
|
||||
* where start/end are character positions in the content.
|
||||
*/
|
||||
function extractImports(content) {
|
||||
const results = [];
|
||||
const importRe = /^import\s+([\s\S]*?)from\s+['"]([^'"]+)['"]\s*;?/gm;
|
||||
|
||||
let match;
|
||||
while ((match = importRe.exec(content)) !== null) {
|
||||
const raw = match[0];
|
||||
const specifierPart = match[1];
|
||||
const moduleName = match[2];
|
||||
|
||||
const isType = /^type\s+/.test(specifierPart.trimStart());
|
||||
const cleanSpec = specifierPart.replace(/^type\s+/, "").trim();
|
||||
|
||||
let defaultImport = null;
|
||||
let namedStr = null;
|
||||
|
||||
const namespaceMatch = cleanSpec.match(/^\*\s+as\s+(\w+)/);
|
||||
if (namespaceMatch) {
|
||||
defaultImport = `* as ${namespaceMatch[1]}`;
|
||||
} else {
|
||||
const braceIdx = cleanSpec.indexOf("{");
|
||||
if (braceIdx === -1) {
|
||||
const def = cleanSpec.replace(/,$/, "").trim();
|
||||
if (def) defaultImport = def;
|
||||
} else {
|
||||
const beforeBrace = cleanSpec.slice(0, braceIdx).replace(/,$/, "").trim();
|
||||
if (beforeBrace) defaultImport = beforeBrace;
|
||||
const closeBrace = cleanSpec.lastIndexOf("}");
|
||||
namedStr = cleanSpec.slice(braceIdx + 1, closeBrace);
|
||||
}
|
||||
}
|
||||
|
||||
const namedImports = namedStr
|
||||
? namedStr.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
results.push({
|
||||
start: match.index,
|
||||
end: match.index + raw.length,
|
||||
raw,
|
||||
module: moduleName,
|
||||
isType,
|
||||
defaultImport,
|
||||
namedImports,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import merging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a single merged import statement from multiple imports of the same module.
|
||||
*
|
||||
* - All type imports → merged `import type { ... }`
|
||||
* - Mixed type + value → merged value import with inline `type Name` specifiers
|
||||
*/
|
||||
function buildMergedImport(moduleName, importList) {
|
||||
const allType = importList.every((i) => i.isType);
|
||||
|
||||
if (allType) {
|
||||
const allNamed = new Set(importList.flatMap((i) => i.namedImports));
|
||||
const defaultImport = importList.map((i) => i.defaultImport).find(Boolean) ?? null;
|
||||
const parts = [];
|
||||
if (defaultImport) parts.push(defaultImport);
|
||||
if (allNamed.size > 0) parts.push(`{ ${[...allNamed].join(", ")} }`);
|
||||
if (parts.length === 0) return `import type "${moduleName}";`;
|
||||
return `import type ${parts.join(", ")} from "${moduleName}";`;
|
||||
}
|
||||
|
||||
// Mixed: collect value default, type-only named, value named separately
|
||||
let valueDefault = null;
|
||||
const typeNamed = new Set();
|
||||
const valueNamed = new Set();
|
||||
|
||||
for (const imp of importList) {
|
||||
if (imp.defaultImport && !imp.isType) valueDefault = imp.defaultImport;
|
||||
for (const n of imp.namedImports) {
|
||||
(imp.isType ? typeNamed : valueNamed).add(n);
|
||||
}
|
||||
}
|
||||
|
||||
// Build named specifiers: `type X` for type-only, plain for value
|
||||
const typeSpecifiers = [...typeNamed].filter((n) => !valueNamed.has(n)).map((n) => `type ${n}`);
|
||||
const valueSpecifiers = [...valueNamed];
|
||||
const namedParts = [...typeSpecifiers, ...valueSpecifiers];
|
||||
|
||||
const parts = [];
|
||||
if (valueDefault) parts.push(valueDefault);
|
||||
if (namedParts.length > 0) parts.push(`{ ${namedParts.join(", ")} }`);
|
||||
if (parts.length === 0) return `import "${moduleName}";`;
|
||||
return `import ${parts.join(", ")} from "${moduleName}";`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File fixer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fixFile(filePath) {
|
||||
let content = readFileSync(filePath, "utf-8");
|
||||
const imports = extractImports(content);
|
||||
if (imports.length === 0) return null;
|
||||
|
||||
// Group by module
|
||||
const byModule = new Map();
|
||||
for (const imp of imports) {
|
||||
if (!byModule.has(imp.module)) byModule.set(imp.module, []);
|
||||
byModule.get(imp.module).push(imp);
|
||||
}
|
||||
|
||||
if (![...byModule.values()].some((v) => v.length > 1)) return null;
|
||||
|
||||
// Build merged text for each module
|
||||
const mergedMap = new Map();
|
||||
for (const [mod, imps] of byModule) {
|
||||
mergedMap.set(mod, imps.length === 1 ? imps[0].raw : buildMergedImport(mod, imps));
|
||||
}
|
||||
|
||||
// Build replacement list (process in reverse order to preserve character positions)
|
||||
imports.sort((a, b) => a.start - b.start);
|
||||
const placedModules = new Set();
|
||||
const replacements = [];
|
||||
|
||||
for (const imp of imports) {
|
||||
if (!placedModules.has(imp.module)) {
|
||||
replacements.push({ start: imp.start, end: imp.end, replacement: mergedMap.get(imp.module) });
|
||||
placedModules.add(imp.module);
|
||||
} else {
|
||||
// Remove duplicate, including its trailing newline
|
||||
const end = content[imp.end] === "\n" ? imp.end + 1 : imp.end;
|
||||
replacements.push({ start: imp.start, end, replacement: "" });
|
||||
}
|
||||
}
|
||||
|
||||
replacements.sort((a, b) => b.start - a.start);
|
||||
for (const { start, end, replacement } of replacements) {
|
||||
content = content.slice(0, start) + replacement + content.slice(end);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const files = findFiles(srcDir);
|
||||
let fixedCount = 0;
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const original = readFileSync(filePath, "utf-8");
|
||||
const fixed = fixFile(filePath);
|
||||
if (fixed && fixed !== original) {
|
||||
writeFileSync(filePath, fixed, "utf-8");
|
||||
const rel = filePath.replace(projectRoot + "/", "");
|
||||
console.log(`Fixed: ${rel}`);
|
||||
fixedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
const rel = filePath.replace(projectRoot + "/", "");
|
||||
console.error(`Error: ${rel} — ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Fixed ${fixedCount} file${fixedCount !== 1 ? "s" : ""}.`);
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { formatCurrency } from '@/lib/format';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Campaign } from '@/types/entities';
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import type { Campaign } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface CampaignRoiCardsProps {
|
||||
campaigns: Campaign[];
|
||||
@@ -34,9 +33,9 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
|
||||
}, [campaigns]);
|
||||
|
||||
const getHealthColor = (rate: number): string => {
|
||||
if (rate >= 0.1) return 'bg-success-500';
|
||||
if (rate >= 0.05) return 'bg-warning-500';
|
||||
return 'bg-error-500';
|
||||
if (rate >= 0.1) return "bg-success-500";
|
||||
if (rate >= 0.05) return "bg-warning-500";
|
||||
return "bg-error-500";
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -44,17 +43,10 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
|
||||
<h3 className="text-sm font-bold text-primary">Campaign ROI</h3>
|
||||
<div className="flex gap-4 overflow-x-auto pb-1">
|
||||
{sorted.map((campaign) => (
|
||||
<div
|
||||
key={campaign.id}
|
||||
className="min-w-[220px] flex-shrink-0 rounded-xl border border-secondary bg-primary p-4"
|
||||
>
|
||||
<div 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">
|
||||
<span
|
||||
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={cx("size-2 shrink-0 rounded-full", getHealthColor(campaign.conversionRate))} />
|
||||
<span className="truncate text-sm font-semibold text-primary">{campaign.campaignName}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-3 text-xs text-tertiary">
|
||||
@@ -64,23 +56,14 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{campaign.cac === Infinity
|
||||
? '—'
|
||||
: formatCurrency(campaign.cac)}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-primary">{campaign.cac === Infinity ? "—" : formatCurrency(campaign.cac)}</span>
|
||||
<span className="ml-1 text-xs text-tertiary">CAC</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-1 w-full overflow-hidden rounded-full bg-tertiary">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-solid"
|
||||
style={{ width: `${Math.min(campaign.budgetProgress * 100, 100)}%` }}
|
||||
/>
|
||||
<div className="h-full rounded-full bg-brand-solid" style={{ width: `${Math.min(campaign.budgetProgress * 100, 100)}%` }} />
|
||||
</div>
|
||||
<span className="mt-1 block text-xs text-quaternary">
|
||||
{Math.round(campaign.budgetProgress * 100)}% budget used
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-quaternary">{Math.round(campaign.budgetProgress * 100)}% budget used</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { BadgeWithDot } from '@/components/base/badges/badges';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { IntegrationStatus, AuthStatus, LeadIngestionSource } from '@/types/entities';
|
||||
import { BadgeWithDot } from "@/components/base/badges/badges";
|
||||
import type { AuthStatus, IntegrationStatus, LeadIngestionSource } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface IntegrationHealthProps {
|
||||
sources: LeadIngestionSource[];
|
||||
}
|
||||
|
||||
const statusBorderMap: Record<IntegrationStatus, string> = {
|
||||
ACTIVE: 'border-secondary',
|
||||
WARNING: 'border-warning',
|
||||
ERROR: 'border-error',
|
||||
DISABLED: 'border-secondary',
|
||||
ACTIVE: "border-secondary",
|
||||
WARNING: "border-warning",
|
||||
ERROR: "border-error",
|
||||
DISABLED: "border-secondary",
|
||||
};
|
||||
|
||||
const statusBadgeColorMap: Record<IntegrationStatus, 'success' | 'warning' | 'error' | 'gray'> = {
|
||||
ACTIVE: 'success',
|
||||
WARNING: 'warning',
|
||||
ERROR: 'error',
|
||||
DISABLED: 'gray',
|
||||
const statusBadgeColorMap: Record<IntegrationStatus, "success" | "warning" | "error" | "gray"> = {
|
||||
ACTIVE: "success",
|
||||
WARNING: "warning",
|
||||
ERROR: "error",
|
||||
DISABLED: "gray",
|
||||
};
|
||||
|
||||
const authBadgeColorMap: Record<AuthStatus, 'success' | 'warning' | 'error' | 'gray'> = {
|
||||
VALID: 'success',
|
||||
EXPIRING_SOON: 'warning',
|
||||
EXPIRED: 'error',
|
||||
NOT_CONFIGURED: 'gray',
|
||||
const authBadgeColorMap: Record<AuthStatus, "success" | "warning" | "error" | "gray"> = {
|
||||
VALID: "success",
|
||||
EXPIRING_SOON: "warning",
|
||||
EXPIRED: "error",
|
||||
NOT_CONFIGURED: "gray",
|
||||
};
|
||||
|
||||
function formatRelativeTime(isoString: string): string {
|
||||
const diffMs = Date.now() - new Date(isoString).getTime();
|
||||
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`;
|
||||
|
||||
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">
|
||||
{sources.map((source) => {
|
||||
const status = source.integrationStatus ?? 'DISABLED';
|
||||
const authStatus = source.authStatus ?? 'NOT_CONFIGURED';
|
||||
const showAuthBadge = authStatus !== 'VALID';
|
||||
const status = source.integrationStatus ?? "DISABLED";
|
||||
const authStatus = source.authStatus ?? "NOT_CONFIGURED";
|
||||
const showAuthBadge = authStatus !== "VALID";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
className={cx(
|
||||
'rounded-xl border bg-primary p-4',
|
||||
statusBorderMap[status],
|
||||
)}
|
||||
>
|
||||
<div key={source.id} className={cx("rounded-xl border bg-primary p-4", statusBorderMap[status])}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-primary">
|
||||
{source.sourceName}
|
||||
</span>
|
||||
<BadgeWithDot
|
||||
size="sm"
|
||||
type="pill-color"
|
||||
color={statusBadgeColorMap[status]}
|
||||
>
|
||||
<span className="text-sm font-semibold text-primary">{source.sourceName}</span>
|
||||
<BadgeWithDot size="sm" type="pill-color" color={statusBadgeColorMap[status]}>
|
||||
{status}
|
||||
</BadgeWithDot>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-tertiary">
|
||||
{source.leadsReceivedLast24h ?? 0} leads in 24h
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-tertiary">{source.leadsReceivedLast24h ?? 0} leads in 24h</p>
|
||||
|
||||
{source.lastSuccessfulSyncAt && (
|
||||
<p className="mt-0.5 text-xs text-quaternary">
|
||||
Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-quaternary">Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)}</p>
|
||||
)}
|
||||
|
||||
{showAuthBadge && (
|
||||
<div className="mt-2">
|
||||
<BadgeWithDot
|
||||
size="sm"
|
||||
type="pill-color"
|
||||
color={authBadgeColorMap[authStatus]}
|
||||
>
|
||||
Auth: {authStatus.replace(/_/g, ' ')}
|
||||
<BadgeWithDot size="sm" type="pill-color" color={authBadgeColorMap[authStatus]}>
|
||||
Auth: {authStatus.replace(/_/g, " ")}
|
||||
</BadgeWithDot>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'WARNING' || status === 'ERROR') && source.lastErrorMessage && (
|
||||
<p
|
||||
className={cx(
|
||||
'mt-2 text-xs',
|
||||
status === 'ERROR' ? 'text-error-primary' : 'text-warning-primary',
|
||||
)}
|
||||
>
|
||||
{(status === "WARNING" || status === "ERROR") && source.lastErrorMessage && (
|
||||
<p className={cx("mt-2 text-xs", status === "ERROR" ? "text-error-primary" : "text-warning-primary")}>
|
||||
{source.lastErrorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Lead } from '@/types/entities';
|
||||
import { useMemo } from "react";
|
||||
import type { Lead } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface LeadFunnelProps {
|
||||
leads: Lead[];
|
||||
@@ -17,28 +16,24 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
|
||||
const stages = useMemo((): FunnelStage[] => {
|
||||
const total = leads.length;
|
||||
|
||||
const contacted = leads.filter((lead) =>
|
||||
lead.leadStatus === 'CONTACTED' ||
|
||||
lead.leadStatus === 'QUALIFIED' ||
|
||||
lead.leadStatus === 'NURTURING' ||
|
||||
lead.leadStatus === 'APPOINTMENT_SET' ||
|
||||
lead.leadStatus === 'CONVERTED',
|
||||
const contacted = leads.filter(
|
||||
(lead) =>
|
||||
lead.leadStatus === "CONTACTED" ||
|
||||
lead.leadStatus === "QUALIFIED" ||
|
||||
lead.leadStatus === "NURTURING" ||
|
||||
lead.leadStatus === "APPOINTMENT_SET" ||
|
||||
lead.leadStatus === "CONVERTED",
|
||||
).length;
|
||||
|
||||
const appointmentSet = leads.filter((lead) =>
|
||||
lead.leadStatus === 'APPOINTMENT_SET' ||
|
||||
lead.leadStatus === 'CONVERTED',
|
||||
).length;
|
||||
const appointmentSet = leads.filter((lead) => lead.leadStatus === "APPOINTMENT_SET" || lead.leadStatus === "CONVERTED").length;
|
||||
|
||||
const converted = leads.filter((lead) =>
|
||||
lead.leadStatus === 'CONVERTED',
|
||||
).length;
|
||||
const converted = leads.filter((lead) => lead.leadStatus === "CONVERTED").length;
|
||||
|
||||
return [
|
||||
{ label: 'Generated', count: total, color: 'bg-brand-600' },
|
||||
{ label: 'Contacted', count: contacted, color: 'bg-brand-500' },
|
||||
{ label: 'Appointment Set', count: appointmentSet, color: 'bg-brand-400' },
|
||||
{ label: 'Converted', count: converted, color: 'bg-success-500' },
|
||||
{ label: "Generated", count: total, color: "bg-brand-600" },
|
||||
{ label: "Contacted", count: contacted, color: "bg-brand-500" },
|
||||
{ label: "Appointment Set", count: appointmentSet, color: "bg-brand-400" },
|
||||
{ label: "Converted", count: converted, color: "bg-success-500" },
|
||||
];
|
||||
}, [leads]);
|
||||
|
||||
@@ -52,10 +47,7 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
|
||||
{stages.map((stage, index) => {
|
||||
const widthPercent = maxCount > 0 ? (stage.count / maxCount) * 100 : 0;
|
||||
const previousCount = index > 0 ? stages[index - 1].count : null;
|
||||
const conversionRate =
|
||||
previousCount !== null && previousCount > 0
|
||||
? ((stage.count / previousCount) * 100).toFixed(0)
|
||||
: null;
|
||||
const conversionRate = previousCount !== null && previousCount > 0 ? ((stage.count / previousCount) * 100).toFixed(0) : null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-primary">{stage.count}</span>
|
||||
{conversionRate !== null && (
|
||||
<span className="text-xs text-tertiary">({conversionRate}%)</span>
|
||||
)}
|
||||
{conversionRate !== null && <span className="text-xs text-tertiary">({conversionRate}%)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 w-full overflow-hidden rounded-md bg-secondary">
|
||||
<div
|
||||
className={cx('h-full rounded-md transition-all', stage.color)}
|
||||
style={{ width: `${Math.max(widthPercent, 2)}%` }}
|
||||
/>
|
||||
<div className={cx("h-full rounded-md transition-all", stage.color)} style={{ width: `${Math.max(widthPercent, 2)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { FC } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircleCheck, faTriangleExclamation, faCircleExclamation } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Lead } from '@/types/entities';
|
||||
import { type FC, useMemo } from "react";
|
||||
import { faCircleCheck, faCircleExclamation, faTriangleExclamation } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import type { Lead } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} 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 responseTimes: number[] = [];
|
||||
let withinSla = 0;
|
||||
let total = leads.length;
|
||||
const total = leads.length;
|
||||
|
||||
for (const lead of leads) {
|
||||
if (lead.createdAt && lead.firstContactedAt) {
|
||||
@@ -36,39 +34,40 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
|
||||
// Leads without firstContactedAt are counted as outside SLA
|
||||
}
|
||||
|
||||
const avgHours =
|
||||
responseTimes.length > 0
|
||||
? responseTimes.reduce((sum, h) => sum + h, 0) / responseTimes.length
|
||||
: 0;
|
||||
const avgHours = responseTimes.length > 0 ? responseTimes.reduce((sum, h) => sum + h, 0) / responseTimes.length : 0;
|
||||
|
||||
const slaPercent = total > 0 ? (withinSla / total) * 100 : 0;
|
||||
|
||||
return { avgHours, withinSla, total, slaPercent };
|
||||
}, [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;
|
||||
|
||||
if (diff <= 0) {
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
label: 'Below target',
|
||||
colorClass: 'text-success-primary',
|
||||
label: "Below target",
|
||||
colorClass: "text-success-primary",
|
||||
};
|
||||
}
|
||||
|
||||
if (diff <= 0.5) {
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
label: 'Near target',
|
||||
colorClass: 'text-warning-primary',
|
||||
label: "Near target",
|
||||
colorClass: "text-warning-primary",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
label: 'Above target',
|
||||
colorClass: 'text-error-primary',
|
||||
label: "Above target",
|
||||
colorClass: "text-error-primary",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -80,18 +79,14 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
|
||||
<h3 className="text-sm font-bold text-primary">Response SLA</h3>
|
||||
|
||||
<div className="mt-4 flex items-end gap-3">
|
||||
<span className="text-display-sm font-bold text-primary">
|
||||
{metrics.avgHours.toFixed(1)}h
|
||||
</span>
|
||||
<span className="text-display-sm font-bold text-primary">{metrics.avgHours.toFixed(1)}h</span>
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<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>
|
||||
|
||||
<span className={cx('mt-1 block text-xs font-medium', status.colorClass)}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span className={cx("mt-1 block text-xs font-medium", status.colorClass)}>{status.label}</span>
|
||||
|
||||
<div className="mt-4">
|
||||
<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={cx(
|
||||
'h-full rounded-full transition-all',
|
||||
metrics.slaPercent >= 80
|
||||
? 'bg-success-500'
|
||||
: metrics.slaPercent >= 60
|
||||
? 'bg-warning-500'
|
||||
: 'bg-error-500',
|
||||
"h-full rounded-full transition-all",
|
||||
metrics.slaPercent >= 80 ? "bg-success-500" : metrics.slaPercent >= 60 ? "bg-warning-500" : "bg-error-500",
|
||||
)}
|
||||
style={{ width: `${metrics.slaPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { BadgeWithDot } from '@/components/base/badges/badges';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Lead, Call, Agent } from '@/types/entities';
|
||||
import { useMemo } from "react";
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import { BadgeWithDot } from "@/components/base/badges/badges";
|
||||
import type { Agent, Call, Lead } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface TeamScoreboardProps {
|
||||
leads: Lead[];
|
||||
@@ -25,16 +24,14 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
|
||||
const leadsProcessed = leads.filter((lead) => lead.assignedAgent === agentName).length;
|
||||
const agentCalls = calls.filter((call) => call.agentName === agentName);
|
||||
const callsMade = agentCalls.length;
|
||||
const appointmentsBooked = agentCalls.filter(
|
||||
(call) => call.disposition === 'APPOINTMENT_BOOKED',
|
||||
).length;
|
||||
const appointmentsBooked = agentCalls.filter((call) => call.disposition === "APPOINTMENT_BOOKED").length;
|
||||
|
||||
return { agent, leadsProcessed, callsMade, appointmentsBooked };
|
||||
});
|
||||
}, [leads, calls, agents]);
|
||||
|
||||
const bestPerformerId = useMemo(() => {
|
||||
let bestId = '';
|
||||
let bestId = "";
|
||||
let maxAppointments = -1;
|
||||
|
||||
for (const stat of agentStats) {
|
||||
@@ -56,29 +53,19 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
|
||||
<div
|
||||
key={agent.id}
|
||||
className={cx(
|
||||
'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',
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar
|
||||
initials={agent.initials ?? undefined}
|
||||
size="md"
|
||||
status={agent.isOnShift ? 'online' : 'offline'}
|
||||
/>
|
||||
<Avatar initials={agent.initials ?? undefined} size="md" status={agent.isOnShift ? "online" : "offline"} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<span className="text-sm font-semibold text-primary">{agent.name}</span>
|
||||
<BadgeWithDot
|
||||
size="sm"
|
||||
type="pill-color"
|
||||
color={agent.isOnShift ? 'success' : 'gray'}
|
||||
>
|
||||
{agent.isOnShift ? 'On Shift' : 'Off Shift'}
|
||||
<BadgeWithDot size="sm" type="pill-color" color={agent.isOnShift ? "success" : "gray"}>
|
||||
{agent.isOnShift ? "On Shift" : "Off Shift"}
|
||||
</BadgeWithDot>
|
||||
</div>
|
||||
{isBest && (
|
||||
<span className="text-xs font-medium text-success-primary">Top</span>
|
||||
)}
|
||||
{isBest && <span className="text-xs font-medium text-success-primary">Top</span>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -96,9 +83,7 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-tertiary">Avg Response</span>
|
||||
<span className="text-lg font-bold text-primary">
|
||||
{agent.avgResponseHours ?? '—'}h
|
||||
</span>
|
||||
<span className="text-lg font-bold text-primary">{agent.avgResponseHours ?? "—"}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { faBars, faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faXmark, faBars } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Dialog as AriaDialog,
|
||||
|
||||
@@ -6,10 +6,12 @@ export type NavItemType = {
|
||||
/** URL to navigate to when the nav item is clicked. */
|
||||
href?: string;
|
||||
/** Icon component to display. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
icon?: FC<Record<string, any>>;
|
||||
/** Badge to display. */
|
||||
badge?: ReactNode;
|
||||
/** 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 }[];
|
||||
/** Whether this nav item is a divider. */
|
||||
divider?: boolean;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { faBell, faGear, faLifeRing, faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||
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 { Avatar } from "@/components/base/avatar/avatar";
|
||||
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 { 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 = {
|
||||
/** Label text for the nav item. */
|
||||
label: string;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Input } from "@/components/base/input/input";
|
||||
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 type { NavItemType } from "../config";
|
||||
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
|
||||
interface SidebarNavigationDualTierProps {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Input } from "@/components/base/input/input";
|
||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||
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 type { NavItemDividerType, NavItemType } from "../config";
|
||||
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
|
||||
interface SidebarNavigationSectionDividersProps {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Input } from "@/components/base/input/input";
|
||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||
import { cx } from "@/utils/cx";
|
||||
@@ -12,6 +10,8 @@ import { NavItemBase } from "../base-components/nav-item";
|
||||
import { NavList } from "../base-components/nav-list";
|
||||
import type { NavItemType } from "../config";
|
||||
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
|
||||
interface SidebarNavigationProps {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { type FC, useState } from "react";
|
||||
import { faArrowRightFromBracket, faGear, faLifeRing } from "@fortawesome/pro-duotone-svg-icons";
|
||||
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 { Button as AriaButton, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
||||
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 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 {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
|
||||
import { Fragment, useContext, useState } from "react";
|
||||
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { type FC, Fragment, type HTMLAttributes, type PropsWithChildren, useContext, useState } from "react";
|
||||
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||
|
||||
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
|
||||
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
|
||||
import type { CalendarProps as AriaCalendarProps, DateValue } from "react-aria-components";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
|
||||
import {
|
||||
Calendar as AriaCalendar,
|
||||
CalendarContext as AriaCalendarContext,
|
||||
@@ -14,8 +9,10 @@ import {
|
||||
CalendarGridBody as AriaCalendarGridBody,
|
||||
CalendarGridHeader as AriaCalendarGridHeader,
|
||||
CalendarHeaderCell as AriaCalendarHeaderCell,
|
||||
type CalendarProps as AriaCalendarProps,
|
||||
CalendarStateContext as AriaCalendarStateContext,
|
||||
Heading as AriaHeading,
|
||||
type DateValue,
|
||||
useSlottedContext,
|
||||
} from "react-aria-components";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
@@ -23,6 +20,9 @@ import { cx } from "@/utils/cx";
|
||||
import { CalendarCell } from "./cell";
|
||||
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) => {
|
||||
const [value, onChange] = useState<DateValue | null>(null);
|
||||
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { getDayOfWeek, getLocalTimeZone, isToday } from "@internationalized/date";
|
||||
import type { CalendarCellProps as AriaCalendarCellProps } from "react-aria-components";
|
||||
import { CalendarCell as AriaCalendarCell, RangeCalendarContext, useLocale, useSlottedContext } from "react-aria-components";
|
||||
import {
|
||||
CalendarCell as AriaCalendarCell,
|
||||
type CalendarCellProps as AriaCalendarCellProps,
|
||||
RangeCalendarContext,
|
||||
useLocale,
|
||||
useSlottedContext,
|
||||
} from "react-aria-components";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface CalendarCellProps extends AriaCalendarCellProps {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { DateInputProps as AriaDateInputProps } from "react-aria-components";
|
||||
import { DateInput as AriaDateInput, DateSegment as AriaDateSegment } from "react-aria-components";
|
||||
import { DateInput as AriaDateInput, type DateInputProps as AriaDateInputProps, DateSegment as AriaDateSegment } from "react-aria-components";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface DateInputProps extends Omit<AriaDateInputProps, "children"> {}
|
||||
type DateInputProps = Omit<AriaDateInputProps, "children">;
|
||||
|
||||
export const DateInput = (props: DateInputProps) => {
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
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 { 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 type { DatePickerProps as AriaDatePickerProps, DateValue } from "react-aria-components";
|
||||
import { DatePicker as AriaDatePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover } from "react-aria-components";
|
||||
import {
|
||||
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 { cx } from "@/utils/cx";
|
||||
import { Calendar } from "./calendar";
|
||||
|
||||
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
|
||||
|
||||
const highlightedDates = [today(getLocalTimeZone())];
|
||||
|
||||
interface DatePickerProps extends AriaDatePickerProps<DateValue> {
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import type { FC } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { type FC, 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 { 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 type { DateRangePickerProps as AriaDateRangePickerProps, DateValue } from "react-aria-components";
|
||||
import { DateRangePicker as AriaDateRangePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover, useLocale } from "react-aria-components";
|
||||
import {
|
||||
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 { cx } from "@/utils/cx";
|
||||
import { DateInput } from "./date-input";
|
||||
import { RangeCalendar } from "./range-calendar";
|
||||
import { RangePresetButton } from "./range-preset";
|
||||
|
||||
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
|
||||
|
||||
const now = today(getLocalTimeZone());
|
||||
|
||||
const highlightedDates = [today(getLocalTimeZone())];
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
|
||||
import { Fragment, useContext, useState } from "react";
|
||||
import type { CalendarDate } from "@internationalized/date";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { type FC, Fragment, type HTMLAttributes, type PropsWithChildren, useContext, useState } from "react";
|
||||
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||
|
||||
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
|
||||
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import type { CalendarDate } from "@internationalized/date";
|
||||
import { useDateFormatter } from "react-aria";
|
||||
import type { RangeCalendarProps as AriaRangeCalendarProps, DateValue } from "react-aria-components";
|
||||
import {
|
||||
CalendarGrid as AriaCalendarGrid,
|
||||
CalendarGridBody as AriaCalendarGridBody,
|
||||
CalendarGridHeader as AriaCalendarGridHeader,
|
||||
CalendarHeaderCell as AriaCalendarHeaderCell,
|
||||
RangeCalendar as AriaRangeCalendar,
|
||||
type RangeCalendarProps as AriaRangeCalendarProps,
|
||||
type DateValue,
|
||||
RangeCalendarContext,
|
||||
RangeCalendarStateContext,
|
||||
useSlottedContext,
|
||||
@@ -23,6 +20,9 @@ import { useBreakpoint } from "@/hooks/use-breakpoint";
|
||||
import { CalendarCell } from "./cell";
|
||||
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) => {
|
||||
const [value, onChange] = useState<{ start: DateValue; end: DateValue } | null>(null);
|
||||
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
|
||||
import { Children, createContext, isValidElement, useContext } from "react";
|
||||
import { FileIcon } from "@untitledui/file-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Children, type ComponentProps, type ComponentPropsWithRef, type FC, createContext, isValidElement, useContext } from "react";
|
||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { FileIcon } from "@untitledui/file-icons";
|
||||
import { FeaturedIcon as FeaturedIconbase } from "@/components/foundations/featured-icon/featured-icon";
|
||||
import type { BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
|
||||
import { BackgroundPattern } from "@/components/shared-assets/background-patterns";
|
||||
import { BackgroundPattern, type BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
|
||||
import { Illustration as Illustrations } from "@/components/shared-assets/illustrations";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
|
||||
interface RootContextProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import type { FileIcon } from "@untitledui/file-icons";
|
||||
import { FileIcon as FileTypeIcon } from "@untitledui/file-icons";
|
||||
import { type ComponentProps, type ComponentPropsWithRef, type FC, useId, useRef, useState } from "react";
|
||||
import { faCircleCheck, faCircleXmark, faCloudArrowUp, faTrash } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleCheck, faTrash, faCloudArrowUp, faCircleXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||
|
||||
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
|
||||
import { type FileIcon, FileIcon as FileTypeIcon } from "@untitledui/file-icons";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
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 { cx } from "@/utils/cx";
|
||||
|
||||
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
|
||||
|
||||
/**
|
||||
* Returns a human-readable file size.
|
||||
* @param bytes - The size of the file in bytes.
|
||||
* @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) => {
|
||||
if (bytes === 0) return "0 KB";
|
||||
|
||||
@@ -388,6 +387,7 @@ const FileUploadList = (props: ComponentPropsWithRef<"ul">) => (
|
||||
</ul>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const FileUpload = {
|
||||
Root: FileUploadRoot,
|
||||
List: FileUploadList,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { DialogProps as AriaDialogProps, ModalOverlayProps as AriaModalOverlayProps } from "react-aria-components";
|
||||
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
|
||||
import {
|
||||
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";
|
||||
|
||||
export const DialogTrigger = AriaDialogTrigger;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { faCircleCheck, faCircleExclamation, faCircleInfo } from "@fortawesome/pro-duotone-svg-icons";
|
||||
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 { Button } from "@/components/base/buttons/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 { 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 = {
|
||||
default: InfoCircle,
|
||||
brand: InfoCircle,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ToasterProps } from "sonner";
|
||||
import { Toaster as SonnerToaster, useSonner } from "sonner";
|
||||
import { Toaster as SonnerToaster, type ToasterProps, useSonner } from "sonner";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
export const DEFAULT_TOAST_POSITION = "bottom-right";
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { CSSProperties, FC, HTMLAttributes, ReactNode } from "react";
|
||||
import React, { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useState } from "react";
|
||||
import React, {
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
type PaginationPage = {
|
||||
/** The type of the pagination item. */
|
||||
@@ -46,9 +56,8 @@ export interface PaginationRootProps {
|
||||
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 [pages, setPages] = useState<PaginationItemType[]>([]);
|
||||
|
||||
const createPaginationItems = useCallback((): PaginationItemType[] => {
|
||||
const items: PaginationItemType[] = [];
|
||||
// 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;
|
||||
}, [total, siblingCount, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const paginationItems = createPaginationItems();
|
||||
setPages(paginationItems);
|
||||
}, [createPaginationItems]);
|
||||
const pages = useMemo(() => createPaginationItems(), [createPaginationItems]);
|
||||
|
||||
const onPageChangeHandler = (newPage: number) => {
|
||||
onPageChange?.(newPage);
|
||||
@@ -207,6 +213,7 @@ interface TriggerProps {
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
|
||||
const context = useContext(PaginationContext);
|
||||
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" />;
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
|
||||
|
||||
interface PaginationItemRenderProps {
|
||||
@@ -281,6 +290,7 @@ export interface PaginationItemProps {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
|
||||
const context = useContext(PaginationContext);
|
||||
if (!context) {
|
||||
@@ -343,6 +353,7 @@ interface PaginationEllipsisProps {
|
||||
className?: string | (() => string);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
|
||||
const computedClassName = typeof className === "function" ? className() : className;
|
||||
|
||||
@@ -357,6 +368,7 @@ interface PaginationContextComponentProps {
|
||||
children: (pagination: PaginationContextType) => ReactNode;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
|
||||
const context = useContext(PaginationContext);
|
||||
if (!context) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cx } from "@/utils/cx";
|
||||
import type { PaginationRootProps } from "./pagination-base";
|
||||
import { Pagination } from "./pagination-base";
|
||||
import { Pagination, type PaginationRootProps } from "./pagination-base";
|
||||
|
||||
interface PaginationDotProps extends Omit<PaginationRootProps, "children"> {
|
||||
/** The size of the pagination dot. */
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cx } from "@/utils/cx";
|
||||
import type { PaginationRootProps } from "./pagination-base";
|
||||
import { Pagination } from "./pagination-base";
|
||||
import { Pagination, type PaginationRootProps } from "./pagination-base";
|
||||
|
||||
interface PaginationLineProps extends Omit<PaginationRootProps, "children"> {
|
||||
/** The size of the pagination line. */
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { type ComponentPropsWithRef, type ReactNode, type RefAttributes } from "react";
|
||||
import type {
|
||||
DialogProps as AriaDialogProps,
|
||||
ModalOverlayProps as AriaModalOverlayProps,
|
||||
ModalRenderProps as AriaModalRenderProps,
|
||||
import {
|
||||
Dialog as AriaDialog,
|
||||
type DialogProps as AriaDialogProps,
|
||||
DialogTrigger as AriaDialogTrigger,
|
||||
Modal as AriaModal,
|
||||
ModalOverlay as AriaModalOverlay,
|
||||
type ModalOverlayProps as AriaModalOverlayProps,
|
||||
type ModalRenderProps as AriaModalRenderProps,
|
||||
} 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 { cx } from "@/utils/cx";
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { ComponentPropsWithRef, ReactNode } from "react";
|
||||
import { Fragment, createContext, useContext } from "react";
|
||||
import type { TabListProps as AriaTabListProps, TabProps as AriaTabProps, TabRenderProps as AriaTabRenderProps } from "react-aria-components";
|
||||
import { Tab as AriaTab, TabList as AriaTabList, TabPanel as AriaTabPanel, Tabs as AriaTabs, TabsContext, useSlottedContext } from "react-aria-components";
|
||||
import { type ComponentPropsWithRef, Fragment, type ReactNode, createContext, useContext } from "react";
|
||||
import {
|
||||
Tab as AriaTab,
|
||||
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 { Badge } from "@/components/base/badges/badges";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { cx } from "@/utils/cx";
|
||||
import { type AvatarProps } from "./avatar";
|
||||
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { cx } from "@/utils/cx";
|
||||
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPlus } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import type { ButtonProps as AriaButtonProps } from "react-aria-components";
|
||||
import { Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
export * from "./avatar-add-button";
|
||||
export * from "./avatar-company-icon";
|
||||
export * from "./avatar-online-indicator";
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { type FC, type ReactNode, isValidElement } from "react";
|
||||
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||
|
||||
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
import { isReactComponent } from "@/utils/is-react-component";
|
||||
|
||||
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
||||
|
||||
type Size = "md" | "lg";
|
||||
type Color = "brand" | "warning" | "error" | "gray" | "success";
|
||||
type Theme = "light" | "modern";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { FC, MouseEventHandler, ReactNode } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||
|
||||
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Dot } from "@/components/foundations/dot-icon";
|
||||
import { cx } from "@/utils/cx";
|
||||
import type { BadgeColors, BadgeTypeToColorMap, BadgeTypes, FlagTypes, IconComponentType, Sizes } from "./badge-types";
|
||||
import { badgeTypes } from "./badge-types";
|
||||
import { type BadgeColors, type BadgeTypeToColorMap, type BadgeTypes, type FlagTypes, type IconComponentType, type Sizes, 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 }> = {
|
||||
gray: {
|
||||
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
import { isReactComponent } from "@/utils/is-react-component";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const styles = sortCx({
|
||||
common: {
|
||||
root: [
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type DetailedHTMLProps, type FC, type ReactNode, isValidElement } from "react";
|
||||
import type { Placement } from "react-aria";
|
||||
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
||||
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
||||
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
|
||||
import { Tooltip } from "@/components/base/tooltip/tooltip";
|
||||
import { cx } from "@/utils/cx";
|
||||
import { isReactComponent } from "@/utils/is-react-component";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const styles = {
|
||||
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",
|
||||
@@ -63,27 +62,20 @@ export const ButtonUtility = ({
|
||||
const href = "href" in otherProps ? otherProps.href : undefined;
|
||||
const Component = href ? AriaLink : AriaButton;
|
||||
|
||||
let props = {};
|
||||
|
||||
if (href) {
|
||||
props = {
|
||||
...otherProps,
|
||||
|
||||
href: isDisabled ? undefined : href,
|
||||
|
||||
// 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.
|
||||
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
|
||||
};
|
||||
} else {
|
||||
props = {
|
||||
...otherProps,
|
||||
|
||||
type: otherProps.type || "button",
|
||||
isDisabled,
|
||||
};
|
||||
}
|
||||
const props = href
|
||||
? {
|
||||
...otherProps,
|
||||
href: isDisabled ? undefined : href,
|
||||
// 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.
|
||||
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
|
||||
}
|
||||
: {
|
||||
...otherProps,
|
||||
type: otherProps.type || "button",
|
||||
isDisabled,
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Component
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
|
||||
import React, { isValidElement } from "react";
|
||||
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
||||
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
||||
import React, { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type DetailedHTMLProps, type FC, type ReactNode, isValidElement } from "react";
|
||||
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
import { isReactComponent } from "@/utils/is-react-component";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const styles = sortCx({
|
||||
common: {
|
||||
root: [
|
||||
@@ -192,22 +191,16 @@ export const Button = ({
|
||||
|
||||
noTextPadding = isLinkType || noTextPadding;
|
||||
|
||||
let props = {};
|
||||
|
||||
if (href) {
|
||||
props = {
|
||||
...otherProps,
|
||||
|
||||
href: disabled ? undefined : href,
|
||||
};
|
||||
} else {
|
||||
props = {
|
||||
...otherProps,
|
||||
|
||||
type: otherProps.type || "button",
|
||||
isPending: loading,
|
||||
};
|
||||
}
|
||||
const props = href
|
||||
? {
|
||||
...otherProps,
|
||||
href: disabled ? undefined : href,
|
||||
}
|
||||
: {
|
||||
...otherProps,
|
||||
type: otherProps.type || "button",
|
||||
isPending: loading,
|
||||
};
|
||||
|
||||
return (
|
||||
<Component
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
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 { cx } from "@/utils/cx";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps } from "react";
|
||||
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
||||
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
||||
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const styles = sortCx({
|
||||
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",
|
||||
@@ -96,27 +96,20 @@ export const SocialButton = ({ size = "lg", theme = "brand", social, className,
|
||||
|
||||
const Logo = logos[social];
|
||||
|
||||
let props = {};
|
||||
|
||||
if (href) {
|
||||
props = {
|
||||
...otherProps,
|
||||
|
||||
href: disabled ? undefined : href,
|
||||
|
||||
// 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.
|
||||
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
|
||||
};
|
||||
} else {
|
||||
props = {
|
||||
...otherProps,
|
||||
|
||||
type: otherProps.type || "button",
|
||||
isDisabled: disabled,
|
||||
};
|
||||
}
|
||||
const props = href
|
||||
? {
|
||||
...otherProps,
|
||||
href: disabled ? undefined : href,
|
||||
// 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.
|
||||
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
|
||||
}
|
||||
: {
|
||||
...otherProps,
|
||||
type: otherProps.type || "button",
|
||||
isDisabled: disabled,
|
||||
};
|
||||
|
||||
return (
|
||||
<Component
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import type { FC, RefAttributes } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import type {
|
||||
ButtonProps as AriaButtonProps,
|
||||
MenuItemProps as AriaMenuItemProps,
|
||||
MenuProps as AriaMenuProps,
|
||||
PopoverProps as AriaPopoverProps,
|
||||
SeparatorProps as AriaSeparatorProps,
|
||||
} from "react-aria-components";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
Button as AriaButton,
|
||||
type ButtonProps as AriaButtonProps,
|
||||
Header as AriaHeader,
|
||||
Menu as AriaMenu,
|
||||
MenuItem as AriaMenuItem,
|
||||
type MenuItemProps as AriaMenuItemProps,
|
||||
type MenuProps as AriaMenuProps,
|
||||
MenuSection as AriaMenuSection,
|
||||
MenuTrigger as AriaMenuTrigger,
|
||||
Popover as AriaPopover,
|
||||
type PopoverProps as AriaPopoverProps,
|
||||
Separator as AriaSeparator,
|
||||
type SeparatorProps as AriaSeparatorProps,
|
||||
} from "react-aria-components";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
@@ -31,6 +29,7 @@ interface DropdownItemProps extends AriaMenuItemProps {
|
||||
icon?: FC<{ className?: string }>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }: DropdownItemProps) => {
|
||||
if (unstyled) {
|
||||
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>) => {
|
||||
return (
|
||||
<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) => {
|
||||
return (
|
||||
<AriaPopover
|
||||
@@ -127,10 +128,12 @@ const DropdownPopover = (props: DropdownPopoverProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const DropdownSeparator = (props: AriaSeparatorProps) => {
|
||||
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>) => {
|
||||
return (
|
||||
<AriaButton
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { DetailedReactHTMLElement, HTMLAttributes, ReactNode } from "react";
|
||||
import React, { cloneElement, useRef } from "react";
|
||||
import React, { type DetailedReactHTMLElement, type HTMLAttributes, type ReactNode, cloneElement, useRef } from "react";
|
||||
import { filterDOMProps } from "@react-aria/utils";
|
||||
|
||||
interface FileTriggerProps {
|
||||
@@ -42,6 +41,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
|
||||
const clonableElement = React.Children.only(children);
|
||||
|
||||
// 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>, {
|
||||
onClick: () => {
|
||||
if (inputRef.current?.value) {
|
||||
@@ -63,7 +63,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
|
||||
onChange={(e) => onSelect?.(e.target.files)}
|
||||
capture={defaultCamera}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import type { TextProps as AriaTextProps } from "react-aria-components";
|
||||
import { Text as AriaText } from "react-aria-components";
|
||||
import { Text as AriaText, type TextProps as AriaTextProps } from "react-aria-components";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface HintTextProps extends AriaTextProps {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type HTMLAttributes, type ReactNode } from "react";
|
||||
import { HintText } from "@/components/base/input/hint-text";
|
||||
import type { InputBaseProps } from "@/components/base/input/input";
|
||||
import { TextField } from "@/components/base/input/input";
|
||||
import { type InputBaseProps, TextField } from "@/components/base/input/input";
|
||||
import { Label } from "@/components/base/input/label";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useControlledState } from "@react-stately/utils";
|
||||
import { HintText } from "@/components/base/input/hint-text";
|
||||
import type { InputBaseProps } from "@/components/base/input/input";
|
||||
import { InputBase, TextField } from "@/components/base/input/input";
|
||||
import { InputBase, type InputBaseProps, TextField } from "@/components/base/input/input";
|
||||
import { Label } from "@/components/base/input/label";
|
||||
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).
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const formatCardNumber = (number: string) => {
|
||||
// Remove non-numeric characters
|
||||
const cleaned = number.replace(/\D/g, "");
|
||||
@@ -76,7 +76,7 @@ export const formatCardNumber = (number: string) => {
|
||||
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) => {
|
||||
const [cardNumber, setCardNumber] = useControlledState(value, defaultValue || "", (value) => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext } from "react";
|
||||
import { faCircleExclamation, faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleQuestion, faCircleExclamation } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import type { InputProps as AriaInputProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
|
||||
import { Group as AriaGroup, Input as AriaInput, TextField as AriaTextField } from "react-aria-components";
|
||||
import {
|
||||
Group as AriaGroup,
|
||||
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 { Label } from "@/components/base/input/label";
|
||||
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||
@@ -192,9 +197,7 @@ interface BaseProps {
|
||||
}
|
||||
|
||||
interface TextFieldProps
|
||||
extends BaseProps,
|
||||
AriaTextFieldProps,
|
||||
Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName"> {
|
||||
extends BaseProps, AriaTextFieldProps, Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName"> {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import type { LabelProps as AriaLabelProps } from "react-aria-components";
|
||||
import { Label as AriaLabel } from "react-aria-components";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Label as AriaLabel, type LabelProps as AriaLabelProps } from "react-aria-components";
|
||||
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ComponentPropsWithRef } from "react";
|
||||
import { createContext, useContext, useId } from "react";
|
||||
import { type ComponentPropsWithRef, createContext, useContext, useId } from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
@@ -15,6 +14,7 @@ const PinInputContext = createContext<PinInputContextType>({
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const usePinInputContext = () => {
|
||||
const context = useContext(PinInputContext);
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { FocusEventHandler, PointerEventHandler, RefAttributes, RefObject } from "react";
|
||||
import { useCallback, useContext, useRef, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { type FocusEventHandler, type PointerEventHandler, type RefAttributes, type RefObject, useCallback, useContext, useRef, useState } from "react";
|
||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps } from "react-aria-components";
|
||||
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
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 { Label } from "@/components/base/input/label";
|
||||
import { Popover } from "@/components/base/select/popover";
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import type { FC, FocusEventHandler, KeyboardEvent, PointerEventHandler, RefAttributes, RefObject } from "react";
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
type FC,
|
||||
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";
|
||||
|
||||
const SearchIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { FocusScope, useFilter, useFocusManager } from "react-aria";
|
||||
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps, Key } from "react-aria-components";
|
||||
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
|
||||
import type { ListData } from "react-stately";
|
||||
import { useListData } from "react-stately";
|
||||
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,
|
||||
type Key,
|
||||
} from "react-aria-components";
|
||||
import { type ListData, useListData } from "react-stately";
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import type { IconComponentType } from "@/components/base/badges/badge-types";
|
||||
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 { SelectItem } from "./select-item";
|
||||
|
||||
const SearchIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
|
||||
interface ComboBoxValueProps extends AriaGroupProps {
|
||||
size: "sm" | "md";
|
||||
shortcut?: boolean;
|
||||
@@ -133,7 +152,7 @@ export const MultiSelectBase = ({
|
||||
// Resize observer for popover width
|
||||
const onResize = useCallback(() => {
|
||||
if (!placeholderRef.current) return;
|
||||
let divRect = placeholderRef.current?.getBoundingClientRect();
|
||||
const divRect = placeholderRef.current?.getBoundingClientRect();
|
||||
setPopoverWidth(divRect.width + "px");
|
||||
}, [placeholderRef, setPopoverWidth]);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { RefAttributes } from "react";
|
||||
import type { PopoverProps as AriaPopoverProps } from "react-aria-components";
|
||||
import { Popover as AriaPopover } from "react-aria-components";
|
||||
import { Popover as AriaPopover, type PopoverProps as AriaPopoverProps } from "react-aria-components";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface PopoverProps extends AriaPopoverProps, RefAttributes<HTMLElement> {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { isValidElement, useContext } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCheck } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import type { ListBoxItemProps as AriaListBoxItemProps } from "react-aria-components";
|
||||
import { ListBoxItem as AriaListBoxItem, Text as AriaText } from "react-aria-components";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { ListBoxItem as AriaListBoxItem, type ListBoxItemProps as AriaListBoxItemProps, Text as AriaText } from "react-aria-components";
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import { cx } from "@/utils/cx";
|
||||
import { isReactComponent } from "@/utils/is-react-component";
|
||||
import type { SelectItemType } from "./select";
|
||||
import { SelectContext } from "./select";
|
||||
import { SelectContext, type SelectItemType } from "./select";
|
||||
|
||||
const sizes = {
|
||||
sm: "p-2 pr-2.5",
|
||||
@@ -83,11 +81,7 @@ export const SelectItem = ({ label, id, value, avatarUrl, supportingText, isDisa
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
"ml-auto text-fg-brand-primary",
|
||||
size === "sm" ? "size-4" : "size-5",
|
||||
state.isDisabled && "text-fg-disabled",
|
||||
)}
|
||||
className={cx("ml-auto text-fg-brand-primary", size === "sm" ? "size-4" : "size-5", state.isDisabled && "text-fg-disabled")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type SelectHTMLAttributes, useId } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { HintText } from "@/components/base/input/hint-text";
|
||||
import { Label } from "@/components/base/input/label";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { FC, ReactNode, Ref, RefAttributes } from "react";
|
||||
import { createContext, isValidElement } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { type FC, type ReactNode, type Ref, type RefAttributes, createContext, isValidElement } from "react";
|
||||
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import type { SelectProps as AriaSelectProps } from "react-aria-components";
|
||||
import { Button as AriaButton, ListBox as AriaListBox, Select as AriaSelect, SelectValue as AriaSelectValue } from "react-aria-components";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
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 { HintText } from "@/components/base/input/hint-text";
|
||||
import { Label } from "@/components/base/input/label";
|
||||
@@ -47,6 +51,7 @@ interface SelectValueProps {
|
||||
placeholderIcon?: FC | ReactNode;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const sizes = {
|
||||
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
|
||||
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" });
|
||||
|
||||
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SliderProps as AriaSliderProps } from "react-aria-components";
|
||||
import {
|
||||
Label as AriaLabel,
|
||||
Slider as AriaSlider,
|
||||
SliderOutput as AriaSliderOutput,
|
||||
type SliderProps as AriaSliderProps,
|
||||
SliderThumb as AriaSliderThumb,
|
||||
SliderTrack as AriaSliderTrack,
|
||||
} from "react-aria-components";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RefAttributes } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
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 { cx } from "@/utils/cx";
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import React from "react";
|
||||
import type { TextAreaProps as AriaTextAreaProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
|
||||
import { TextArea as AriaTextArea, TextField as AriaTextField } from "react-aria-components";
|
||||
import React, { type ReactNode, type Ref } from "react";
|
||||
import {
|
||||
TextArea as AriaTextArea,
|
||||
type TextAreaProps as AriaTextAreaProps,
|
||||
TextField as AriaTextField,
|
||||
type TextFieldProps as AriaTextFieldProps,
|
||||
} from "react-aria-components";
|
||||
import { HintText } from "@/components/base/input/hint-text";
|
||||
import { Label } from "@/components/base/input/label";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { SwitchProps as AriaSwitchProps } from "react-aria-components";
|
||||
import { Switch as AriaSwitch } from "react-aria-components";
|
||||
import { Switch as AriaSwitch, type SwitchProps as AriaSwitchProps } from "react-aria-components";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface ToggleBaseProps {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
ButtonProps as AriaButtonProps,
|
||||
TooltipProps as AriaTooltipProps,
|
||||
TooltipTriggerComponentProps as AriaTooltipTriggerComponentProps,
|
||||
import {
|
||||
Button as AriaButton,
|
||||
type ButtonProps as AriaButtonProps,
|
||||
OverlayArrow as AriaOverlayArrow,
|
||||
Tooltip as AriaTooltip,
|
||||
type TooltipProps as AriaTooltipProps,
|
||||
TooltipTrigger as AriaTooltipTrigger,
|
||||
type TooltipTriggerComponentProps as AriaTooltipTriggerComponentProps,
|
||||
} 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";
|
||||
|
||||
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) => {
|
||||
return (
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import type { FC } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import type { FC } from "react";
|
||||
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Badge } from "@/components/base/badges/badges";
|
||||
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} />;
|
||||
import { formatShortDate } from '@/lib/format';
|
||||
import type { Call, CallDisposition } from '@/types/entities';
|
||||
|
||||
interface CallLogProps {
|
||||
calls: Call[];
|
||||
}
|
||||
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
|
||||
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
|
||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
|
||||
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' },
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: "success" | "brand" | "blue-light" | "warning" | "gray" | "error" }> = {
|
||||
APPOINTMENT_BOOKED: { label: "Booked", color: "success" },
|
||||
FOLLOW_UP_SCHEDULED: { label: "Follow-up", color: "brand" },
|
||||
INFO_PROVIDED: { label: "Info", color: "blue-light" },
|
||||
NO_ANSWER: { label: "No Answer", color: "warning" },
|
||||
WRONG_NUMBER: { label: "Wrong #", color: "gray" },
|
||||
CALLBACK_REQUESTED: { label: "Not Interested", color: "error" },
|
||||
};
|
||||
|
||||
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);
|
||||
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 gap-2">
|
||||
<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>
|
||||
|
||||
{calls.length > 0 ? (
|
||||
<div className="divide-y divide-secondary">
|
||||
{calls.map((call) => {
|
||||
const config = call.disposition !== null
|
||||
? dispositionConfig[call.disposition]
|
||||
: null;
|
||||
const config = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={call.id}
|
||||
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>
|
||||
<div key={call.id} 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">
|
||||
{call.leadName ?? call.callerNumber?.[0]?.number ?? 'Unknown'}
|
||||
{call.leadName ?? call.callerNumber?.[0]?.number ?? "Unknown"}
|
||||
</span>
|
||||
{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">
|
||||
{formatDuration(call.durationSeconds)}
|
||||
</span>
|
||||
<span className="w-12 shrink-0 text-right text-xs text-quaternary">{formatDuration(call.durationSeconds)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSparkles, faUserPlus } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { formatShortDate } from '@/lib/format';
|
||||
import type { Lead, LeadActivity } from '@/types/entities';
|
||||
import { faSparkles, faUserPlus } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Badge } from "@/components/base/badges/badges";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
import { formatShortDate } from "@/lib/format";
|
||||
import type { Lead, LeadActivity } from "@/types/entities";
|
||||
|
||||
interface CallPrepCardProps {
|
||||
lead: Lead | null;
|
||||
@@ -19,8 +19,8 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
|
||||
const leadActivities = activities
|
||||
.filter((a) => a.leadId === lead.id)
|
||||
.sort((a, b) => {
|
||||
const dateA = a.occurredAt ?? a.createdAt ?? '';
|
||||
const dateB = b.occurredAt ?? b.createdAt ?? '';
|
||||
const dateA = a.occurredAt ?? a.createdAt ?? "";
|
||||
const dateB = b.occurredAt ?? b.createdAt ?? "";
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
})
|
||||
.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="mb-2 flex items-center gap-1.5">
|
||||
<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>
|
||||
|
||||
{lead.aiSummary && (
|
||||
<p className="text-sm text-primary">{lead.aiSummary}</p>
|
||||
)}
|
||||
{lead.aiSummary && <p className="text-sm text-primary">{lead.aiSummary}</p>}
|
||||
|
||||
{lead.aiSuggestedAction && (
|
||||
<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>
|
||||
<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.aiSummary && !lead.aiSuggestedAction && (
|
||||
<p className="text-sm text-quaternary">No AI insights available for this lead.</p>
|
||||
)}
|
||||
{!lead.aiSummary && !lead.aiSuggestedAction && <p className="text-sm text-quaternary">No AI insights available for this lead.</p>}
|
||||
|
||||
{leadActivities.length > 0 && (
|
||||
<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">
|
||||
{leadActivities.map((a) => (
|
||||
<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>
|
||||
{a.occurredAt && (
|
||||
<span className="shrink-0 text-xs text-quaternary">{formatShortDate(a.occurredAt)}</span>
|
||||
)}
|
||||
{a.occurredAt && <span className="shrink-0 text-xs text-quaternary">{formatShortDate(a.occurredAt)}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -70,10 +64,10 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
|
||||
<div className="rounded-xl bg-secondary p-4">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<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>
|
||||
<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>
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<p className="text-xs font-semibold text-secondary">Suggested script:</p>
|
||||
@@ -85,9 +79,11 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button size="sm" color="secondary" iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faUserPlus} className={className} />
|
||||
)}>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faUserPlus} className={className} />}
|
||||
>
|
||||
Create Lead
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhoneArrowUpRight } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { faPhoneArrowUpRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface CallSimulatorProps {
|
||||
onSimulate: () => void;
|
||||
@@ -14,14 +14,14 @@ export const CallSimulator = ({ onSimulate, isCallActive }: CallSimulatorProps)
|
||||
onClick={onSimulate}
|
||||
disabled={isCallActive}
|
||||
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
|
||||
? '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-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]",
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhoneArrowUpRight} className="size-5 shrink-0" />
|
||||
{isCallActive ? 'Call in progress...' : 'Simulate Incoming Call'}
|
||||
{isCallActive ? "Call in progress..." : "Simulate Incoming Call"}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import type { FC } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhone } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { type FC, useState } from "react";
|
||||
import { faPhone } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
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} />;
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { notify } from '@/lib/toast';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => (
|
||||
<FontAwesomeIcon icon={faPhone} className={className} {...rest} />
|
||||
);
|
||||
|
||||
interface ClickToCallButtonProps {
|
||||
phoneNumber: string;
|
||||
leadId?: 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 [dialing, setDialing] = useState(false);
|
||||
|
||||
@@ -24,7 +26,7 @@ export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCa
|
||||
try {
|
||||
await dialOutbound(phoneNumber);
|
||||
} catch {
|
||||
notify.error('Dial Failed', 'Could not place the call');
|
||||
notify.error("Dial Failed", "Could not place the call");
|
||||
} finally {
|
||||
setDialing(false);
|
||||
}
|
||||
@@ -39,7 +41,7 @@ export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCa
|
||||
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
|
||||
isLoading={dialing}
|
||||
>
|
||||
{label ?? 'Call'}
|
||||
{label ?? "Call"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Call } from '@/types/entities';
|
||||
import type { Call } from "@/types/entities";
|
||||
|
||||
interface DailyStatsProps {
|
||||
calls: Call[];
|
||||
}
|
||||
|
||||
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 avgMinutes = totalSeconds / calls.length / 60;
|
||||
return `${avgMinutes.toFixed(1)} min`;
|
||||
@@ -13,29 +13,24 @@ const formatAvgDuration = (calls: Call[]): string => {
|
||||
|
||||
export const DailyStats = ({ calls }: DailyStatsProps) => {
|
||||
const callsHandled = calls.length;
|
||||
const appointmentsBooked = calls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const followUps = calls.filter((c) => c.disposition === 'FOLLOW_UP_SCHEDULED').length;
|
||||
const appointmentsBooked = calls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length;
|
||||
const followUps = calls.filter((c) => c.disposition === "FOLLOW_UP_SCHEDULED").length;
|
||||
const avgDuration = formatAvgDuration(calls);
|
||||
|
||||
const stats = [
|
||||
{ label: 'Calls Handled', value: String(callsHandled) },
|
||||
{ label: 'Appointments Booked', value: String(appointmentsBooked) },
|
||||
{ label: 'Follow-ups', value: String(followUps) },
|
||||
{ label: 'Avg Duration', value: avgDuration },
|
||||
{ label: "Calls Handled", value: String(callsHandled) },
|
||||
{ label: "Appointments Booked", value: String(appointmentsBooked) },
|
||||
{ label: "Follow-ups", value: String(followUps) },
|
||||
{ label: "Avg Duration", value: avgDuration },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-primary">Daily Stats</h3>
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="rounded-xl bg-secondary p-4 text-center"
|
||||
>
|
||||
<div 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="mt-1 text-xs uppercase tracking-wider text-tertiary">
|
||||
{stat.label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs tracking-wider text-tertiary uppercase">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { TextArea } from '@/components/base/textarea/textarea';
|
||||
import type { CallDisposition } from '@/types/entities';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { useState } from "react";
|
||||
import { TextArea } from "@/components/base/textarea/textarea";
|
||||
import type { CallDisposition } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface DispositionFormProps {
|
||||
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
||||
@@ -15,46 +15,46 @@ const dispositionOptions: Array<{
|
||||
defaultClass: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'APPOINTMENT_BOOKED',
|
||||
label: 'Appointment Booked',
|
||||
activeClass: 'bg-success-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-success-primary text-success-primary border-success',
|
||||
value: "APPOINTMENT_BOOKED",
|
||||
label: "Appointment Booked",
|
||||
activeClass: "bg-success-solid text-white ring-transparent",
|
||||
defaultClass: "bg-success-primary text-success-primary border-success",
|
||||
},
|
||||
{
|
||||
value: 'FOLLOW_UP_SCHEDULED',
|
||||
label: 'Follow-up Needed',
|
||||
activeClass: 'bg-brand-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
|
||||
value: "FOLLOW_UP_SCHEDULED",
|
||||
label: "Follow-up Needed",
|
||||
activeClass: "bg-brand-solid text-white ring-transparent",
|
||||
defaultClass: "bg-brand-primary text-brand-secondary border-brand",
|
||||
},
|
||||
{
|
||||
value: 'INFO_PROVIDED',
|
||||
label: 'Info Provided',
|
||||
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',
|
||||
value: "INFO_PROVIDED",
|
||||
label: "Info Provided",
|
||||
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",
|
||||
},
|
||||
{
|
||||
value: 'NO_ANSWER',
|
||||
label: 'No Answer',
|
||||
activeClass: 'bg-warning-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
||||
value: "NO_ANSWER",
|
||||
label: "No Answer",
|
||||
activeClass: "bg-warning-solid text-white ring-transparent",
|
||||
defaultClass: "bg-warning-primary text-warning-primary border-warning",
|
||||
},
|
||||
{
|
||||
value: 'WRONG_NUMBER',
|
||||
label: 'Wrong Number',
|
||||
activeClass: 'bg-secondary-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||
value: "WRONG_NUMBER",
|
||||
label: "Wrong Number",
|
||||
activeClass: "bg-secondary-solid text-white ring-transparent",
|
||||
defaultClass: "bg-secondary text-secondary border-secondary",
|
||||
},
|
||||
{
|
||||
value: 'CALLBACK_REQUESTED',
|
||||
label: 'Not Interested',
|
||||
activeClass: 'bg-error-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||
value: "CALLBACK_REQUESTED",
|
||||
label: "Not Interested",
|
||||
activeClass: "bg-error-solid text-white ring-transparent",
|
||||
defaultClass: "bg-error-primary text-error-primary border-error",
|
||||
},
|
||||
];
|
||||
|
||||
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
|
||||
const [selected, setSelected] = useState<CallDisposition | null>(defaultDisposition ?? null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selected === null) return;
|
||||
@@ -74,10 +74,8 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
||||
type="button"
|
||||
onClick={() => setSelected(option.value)}
|
||||
className={cx(
|
||||
'cursor-pointer rounded-xl border-2 p-3 text-xs font-semibold transition duration-100 ease-linear',
|
||||
isSelected
|
||||
? cx(option.activeClass, 'ring-2 ring-brand')
|
||||
: option.defaultClass,
|
||||
"cursor-pointer rounded-xl border-2 p-3 text-xs font-semibold transition duration-100 ease-linear",
|
||||
isSelected ? cx(option.activeClass, "ring-2 ring-brand") : option.defaultClass,
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
@@ -86,13 +84,7 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
||||
})}
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
label="Notes (optional)"
|
||||
placeholder="Add any notes about this call..."
|
||||
value={notes}
|
||||
onChange={(value) => setNotes(value)}
|
||||
rows={3}
|
||||
/>
|
||||
<TextArea label="Notes (optional)" placeholder="Add any notes about this call..." value={notes} onChange={(value) => setNotes(value)} rows={3} />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
@@ -100,10 +92,10 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
||||
onClick={handleSubmit}
|
||||
disabled={selected === null}
|
||||
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
|
||||
? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover'
|
||||
: 'cursor-not-allowed bg-disabled text-disabled',
|
||||
? "cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover"
|
||||
: "cursor-not-allowed bg-disabled text-disabled",
|
||||
)}
|
||||
>
|
||||
Save & Close Call
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhone, faPhoneArrowDown, faCircleCheck, faEnvelope, faClock, faStars } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { SourceTag } from '@/components/shared/source-tag';
|
||||
import { AgeIndicator } from '@/components/shared/age-indicator';
|
||||
import { DispositionForm } from './disposition-form';
|
||||
import { formatPhone, formatShortDate, getInitials } from '@/lib/format';
|
||||
import type { Lead, LeadActivity, CallDisposition, Campaign } from '@/types/entities';
|
||||
import { useMemo } from "react";
|
||||
import { faCircleCheck, faClock, faEnvelope, faPhone, faPhoneArrowDown, faStars } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import { Badge } from "@/components/base/badges/badges";
|
||||
import { AgeIndicator } from "@/components/shared/age-indicator";
|
||||
import { SourceTag } from "@/components/shared/source-tag";
|
||||
import { formatPhone, formatShortDate, getInitials } from "@/lib/format";
|
||||
import type { CallDisposition, Campaign, Lead, LeadActivity } from "@/types/entities";
|
||||
import { DispositionForm } from "./disposition-form";
|
||||
|
||||
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
|
||||
type CallState = "idle" | "ringing" | "active" | "completed";
|
||||
|
||||
interface IncomingCallCardProps {
|
||||
callState: CallState;
|
||||
@@ -21,57 +21,57 @@ interface IncomingCallCardProps {
|
||||
}
|
||||
|
||||
const activityTypeIcons: Record<string, string> = {
|
||||
CALL_MADE: 'phone',
|
||||
CALL_RECEIVED: 'phone',
|
||||
WHATSAPP_SENT: 'message',
|
||||
WHATSAPP_RECEIVED: 'message',
|
||||
SMS_SENT: 'message',
|
||||
EMAIL_SENT: 'email',
|
||||
EMAIL_RECEIVED: 'email',
|
||||
NOTE_ADDED: 'note',
|
||||
ASSIGNED: 'assign',
|
||||
STATUS_CHANGE: 'status',
|
||||
APPOINTMENT_BOOKED: 'calendar',
|
||||
FOLLOW_UP_CREATED: 'clock',
|
||||
CONVERTED: 'check',
|
||||
MARKED_SPAM: 'alert',
|
||||
DUPLICATE_DETECTED: 'alert',
|
||||
CALL_MADE: "phone",
|
||||
CALL_RECEIVED: "phone",
|
||||
WHATSAPP_SENT: "message",
|
||||
WHATSAPP_RECEIVED: "message",
|
||||
SMS_SENT: "message",
|
||||
EMAIL_SENT: "email",
|
||||
EMAIL_RECEIVED: "email",
|
||||
NOTE_ADDED: "note",
|
||||
ASSIGNED: "assign",
|
||||
STATUS_CHANGE: "status",
|
||||
APPOINTMENT_BOOKED: "calendar",
|
||||
FOLLOW_UP_CREATED: "clock",
|
||||
CONVERTED: "check",
|
||||
MARKED_SPAM: "alert",
|
||||
DUPLICATE_DETECTED: "alert",
|
||||
};
|
||||
|
||||
const ActivityIcon = ({ type }: { type: string }) => {
|
||||
const iconType = activityTypeIcons[type] ?? 'note';
|
||||
const baseClass = 'size-3.5 shrink-0 text-fg-quaternary';
|
||||
const iconType = activityTypeIcons[type] ?? "note";
|
||||
const baseClass = "size-3.5 shrink-0 text-fg-quaternary";
|
||||
|
||||
if (iconType === 'phone') return <FontAwesomeIcon icon={faPhone} className={baseClass} />;
|
||||
if (iconType === 'email') return <FontAwesomeIcon icon={faEnvelope} className={baseClass} />;
|
||||
if (iconType === 'clock') return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
||||
if (iconType === 'check') return <FontAwesomeIcon icon={faCircleCheck} className={baseClass} />;
|
||||
if (iconType === "phone") return <FontAwesomeIcon icon={faPhone} className={baseClass} />;
|
||||
if (iconType === "email") return <FontAwesomeIcon icon={faEnvelope} className={baseClass} />;
|
||||
if (iconType === "clock") return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
||||
if (iconType === "check") return <FontAwesomeIcon icon={faCircleCheck} className={baseClass} />;
|
||||
return <FontAwesomeIcon icon={faClock} className={baseClass} />;
|
||||
};
|
||||
|
||||
const dispositionLabels: Record<CallDisposition, string> = {
|
||||
APPOINTMENT_BOOKED: 'Appointment Booked',
|
||||
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
|
||||
INFO_PROVIDED: 'Info Provided',
|
||||
NO_ANSWER: 'No Answer',
|
||||
WRONG_NUMBER: 'Wrong Number',
|
||||
CALLBACK_REQUESTED: 'Not Interested',
|
||||
APPOINTMENT_BOOKED: "Appointment Booked",
|
||||
FOLLOW_UP_SCHEDULED: "Follow-up Needed",
|
||||
INFO_PROVIDED: "Info Provided",
|
||||
NO_ANSWER: "No Answer",
|
||||
WRONG_NUMBER: "Wrong Number",
|
||||
CALLBACK_REQUESTED: "Not Interested",
|
||||
};
|
||||
|
||||
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
||||
if (callState === 'idle') {
|
||||
if (callState === "idle") {
|
||||
return <IdleState />;
|
||||
}
|
||||
|
||||
if (callState === 'ringing') {
|
||||
if (callState === "ringing") {
|
||||
return <RingingState lead={lead} />;
|
||||
}
|
||||
|
||||
if (callState === 'active' && lead !== null) {
|
||||
if (callState === "active" && lead !== null) {
|
||||
return <ActiveState lead={lead} activities={activities} campaigns={campaigns} onDisposition={onDisposition} />;
|
||||
}
|
||||
|
||||
if (callState === 'completed') {
|
||||
if (callState === "completed") {
|
||||
return <CompletedState disposition={completedDisposition ?? null} />;
|
||||
}
|
||||
|
||||
@@ -88,9 +88,7 @@ const IdleState = () => (
|
||||
);
|
||||
|
||||
const RingingState = ({ lead }: { lead: Lead | null }) => {
|
||||
const phoneDisplay = lead?.contactPhone?.[0]
|
||||
? formatPhone(lead.contactPhone[0])
|
||||
: '+91 98765 43210';
|
||||
const phoneDisplay = lead?.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "+91 98765 43210";
|
||||
|
||||
return (
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
||||
Incoming Call
|
||||
</span>
|
||||
<span className="text-display-xs font-bold text-primary">
|
||||
{phoneDisplay}
|
||||
</span>
|
||||
<span className="mb-1 text-xs font-bold tracking-wider text-brand-secondary uppercase">Incoming Call</span>
|
||||
<span className="text-display-xs font-bold text-primary">{phoneDisplay}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -126,8 +120,8 @@ const ActiveState = ({
|
||||
activities
|
||||
.filter((a) => a.leadId === lead.id)
|
||||
.sort((a, b) => {
|
||||
const dateA = a.occurredAt ?? a.createdAt ?? '';
|
||||
const dateB = b.occurredAt ?? b.createdAt ?? '';
|
||||
const dateA = a.occurredAt ?? a.createdAt ?? "";
|
||||
const dateB = b.occurredAt ?? b.createdAt ?? "";
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
})
|
||||
.slice(0, 3),
|
||||
@@ -140,13 +134,11 @@ const ActiveState = ({
|
||||
return campaign?.campaignName ?? null;
|
||||
}, [campaigns, lead.campaignId]);
|
||||
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
|
||||
const initials = firstName && lastName ? getInitials(firstName, lastName) : 'UL';
|
||||
const phoneDisplay = lead.contactPhone?.[0]
|
||||
? formatPhone(lead.contactPhone[0])
|
||||
: 'No phone';
|
||||
const firstName = lead.contactName?.firstName ?? "";
|
||||
const lastName = lead.contactName?.lastName ?? "";
|
||||
const fullName = `${firstName} ${lastName}`.trim() || "Unknown Lead";
|
||||
const initials = firstName && lastName ? getInitials(firstName, lastName) : "UL";
|
||||
const phoneDisplay = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "No phone";
|
||||
const emailDisplay = lead.contactEmail?.[0]?.address ?? null;
|
||||
|
||||
return (
|
||||
@@ -169,18 +161,14 @@ const ActiveState = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{lead.leadSource !== null && (
|
||||
<SourceTag source={lead.leadSource} size="sm" />
|
||||
)}
|
||||
{lead.leadSource !== null && <SourceTag source={lead.leadSource} size="sm" />}
|
||||
{campaignName !== null && (
|
||||
<Badge size="sm" color="brand">{campaignName}</Badge>
|
||||
<Badge size="sm" color="brand">
|
||||
{campaignName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{lead.interestedService !== null && (
|
||||
<p className="mt-1.5 text-sm text-secondary">
|
||||
Interested in: {lead.interestedService}
|
||||
</p>
|
||||
)}
|
||||
{lead.interestedService !== null && <p className="mt-1.5 text-sm text-secondary">Interested in: {lead.interestedService}</p>}
|
||||
{lead.createdAt !== null && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-sm text-tertiary">
|
||||
<span>Lead age:</span>
|
||||
@@ -194,9 +182,7 @@ const ActiveState = ({
|
||||
<div className="mt-4 rounded-xl bg-brand-primary p-4">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<FontAwesomeIcon icon={faStars} className="size-4 text-fg-brand-primary" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
||||
AI Insight
|
||||
</span>
|
||||
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Insight</span>
|
||||
</div>
|
||||
{lead.aiSummary !== null ? (
|
||||
<>
|
||||
@@ -208,9 +194,7 @@ const ActiveState = ({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -221,14 +205,10 @@ const ActiveState = ({
|
||||
<div className="flex flex-col gap-2">
|
||||
{leadActivities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-2">
|
||||
<ActivityIcon type={activity.activityType ?? 'NOTE_ADDED'} />
|
||||
<span className="flex-1 text-xs text-secondary">
|
||||
{activity.summary}
|
||||
</span>
|
||||
<ActivityIcon type={activity.activityType ?? "NOTE_ADDED"} />
|
||||
<span className="flex-1 text-xs text-secondary">{activity.summary}</span>
|
||||
<span className="shrink-0 text-xs text-quaternary">
|
||||
{activity.occurredAt !== null
|
||||
? formatShortDate(activity.occurredAt)
|
||||
: ''}
|
||||
{activity.occurredAt !== null ? formatShortDate(activity.occurredAt) : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -240,7 +220,7 @@ const ActiveState = ({
|
||||
</div>
|
||||
|
||||
{/* 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} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,14 +229,16 @@ const ActiveState = ({
|
||||
};
|
||||
|
||||
const CompletedState = ({ disposition }: { disposition: CallDisposition | null }) => {
|
||||
const label = disposition !== null ? dispositionLabels[disposition] : 'Unknown';
|
||||
const label = disposition !== null ? dispositionLabels[disposition] : "Unknown";
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<h3 className="text-lg font-bold text-success-primary">Call Logged</h3>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhone, faCommentDots, faEllipsisVertical, faMessageDots } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { faCommentDots, faEllipsisVertical, faMessageDots, faPhone } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { useSip } from "@/providers/sip-provider";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
type PhoneActionCellProps = {
|
||||
phoneNumber: string;
|
||||
@@ -27,8 +27,8 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [menuOpen]);
|
||||
|
||||
const handleCall = async () => {
|
||||
@@ -39,7 +39,7 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
||||
onDial?.();
|
||||
await dialOutbound(phoneNumber);
|
||||
} catch {
|
||||
notify.error('Dial Failed', 'Could not place the call');
|
||||
notify.error("Dial Failed", "Could not place the call");
|
||||
} finally {
|
||||
setDialing(false);
|
||||
}
|
||||
@@ -47,12 +47,12 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
||||
|
||||
const handleSms = () => {
|
||||
setMenuOpen(false);
|
||||
window.open(`sms:+91${phoneNumber}`, '_self');
|
||||
window.open(`sms:+91${phoneNumber}`, "_self");
|
||||
};
|
||||
|
||||
const handleWhatsApp = () => {
|
||||
setMenuOpen(false);
|
||||
window.open(`https://wa.me/91${phoneNumber}`, '_blank');
|
||||
window.open(`https://wa.me/91${phoneNumber}`, "_blank");
|
||||
};
|
||||
|
||||
// Long-press for mobile
|
||||
@@ -77,13 +77,14 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
||||
onClick={handleCall}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
disabled={!canCall}
|
||||
className={cx(
|
||||
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear',
|
||||
canCall
|
||||
? 'cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary'
|
||||
: 'cursor-default text-tertiary',
|
||||
"flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear",
|
||||
canCall ? "cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary" : "cursor-default text-tertiary",
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
||||
@@ -93,15 +94,18 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
||||
{/* Kebab menu trigger — desktop */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
|
||||
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"
|
||||
onClick={(e) => {
|
||||
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" />
|
||||
</button>
|
||||
|
||||
{/* Context menu */}
|
||||
{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
|
||||
type="button"
|
||||
onClick={handleCall}
|
||||
|
||||
@@ -1,61 +1,49 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
import { formatCurrency, formatCompact } from '@/lib/format';
|
||||
import { AdStatusBadge } from '@/components/shared/status-badge';
|
||||
import type { Ad, AdFormat } from '@/types/entities';
|
||||
import { AdStatusBadge } from "@/components/shared/status-badge";
|
||||
import { formatCompact, formatCurrency } from "@/lib/format";
|
||||
import type { Ad, AdFormat } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface AdCardProps {
|
||||
ad: Ad;
|
||||
}
|
||||
|
||||
const formatPreviewStyles: Record<AdFormat, { bg: string; icon: string }> = {
|
||||
IMAGE: { bg: 'bg-brand-solid', icon: 'IMG' },
|
||||
VIDEO: { bg: 'bg-fg-brand-secondary', icon: 'VID' },
|
||||
CAROUSEL: { bg: 'bg-error-solid', icon: 'CAR' },
|
||||
TEXT: { bg: 'bg-fg-tertiary', icon: 'TXT' },
|
||||
LEAD_FORM: { bg: 'bg-success-solid', icon: 'FORM' },
|
||||
IMAGE: { bg: "bg-brand-solid", icon: "IMG" },
|
||||
VIDEO: { bg: "bg-fg-brand-secondary", icon: "VID" },
|
||||
CAROUSEL: { bg: "bg-error-solid", icon: "CAR" },
|
||||
TEXT: { bg: "bg-fg-tertiary", icon: "TXT" },
|
||||
LEAD_FORM: { bg: "bg-success-solid", icon: "FORM" },
|
||||
};
|
||||
|
||||
const formatBadgeLabel = (format: AdFormat): string =>
|
||||
format.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const formatBadgeLabel = (format: AdFormat): string => format.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
export const AdCard = ({ ad }: AdCardProps) => {
|
||||
const format = ad.adFormat ?? 'IMAGE';
|
||||
const format = ad.adFormat ?? "IMAGE";
|
||||
const preview = formatPreviewStyles[format] ?? formatPreviewStyles.IMAGE;
|
||||
const currencyCode = ad.spend?.currencyCode ?? 'INR';
|
||||
const currencyCode = ad.spend?.currencyCode ?? "INR";
|
||||
|
||||
const metrics = [
|
||||
{ label: 'Impr.', value: formatCompact(ad.impressions ?? 0) },
|
||||
{ label: 'Clicks', value: formatCompact(ad.clicks ?? 0) },
|
||||
{ label: 'Leads', value: String(ad.conversions ?? 0) },
|
||||
{ label: 'Spend', value: ad.spend ? formatCurrency(ad.spend.amountMicros, currencyCode) : '--' },
|
||||
{ label: "Impr.", value: formatCompact(ad.impressions ?? 0) },
|
||||
{ label: "Clicks", value: formatCompact(ad.clicks ?? 0) },
|
||||
{ label: "Leads", value: String(ad.conversions ?? 0) },
|
||||
{ label: "Spend", value: ad.spend ? formatCurrency(ad.spend.amountMicros, currencyCode) : "--" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-xl border border-secondary bg-primary p-4 transition hover:shadow-sm">
|
||||
{/* Preview thumbnail */}
|
||||
<div
|
||||
className={cx(
|
||||
'flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white',
|
||||
preview.bg,
|
||||
)}
|
||||
>
|
||||
<div 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}
|
||||
</div>
|
||||
|
||||
{/* Ad info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="truncate text-sm font-bold text-primary">
|
||||
{ad.adName ?? 'Untitled Ad'}
|
||||
</h4>
|
||||
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-tertiary">
|
||||
{formatBadgeLabel(format)}
|
||||
</span>
|
||||
<h4 className="truncate text-sm font-bold text-primary">{ad.adName ?? "Untitled Ad"}</h4>
|
||||
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-tertiary">{formatBadgeLabel(format)}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-quaternary">{ad.externalAdId ?? ad.id.slice(0, 12)}</p>
|
||||
{ad.headline && (
|
||||
<p className="mt-1 truncate text-xs text-tertiary">{ad.headline}</p>
|
||||
)}
|
||||
{ad.headline && <p className="mt-1 truncate text-xs text-tertiary">{ad.headline}</p>}
|
||||
</div>
|
||||
|
||||
{/* Inline metrics */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
import { formatCurrency } from '@/lib/format';
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
type CurrencyAmount = {
|
||||
amountMicros: number;
|
||||
@@ -18,15 +18,10 @@ export const BudgetBar = ({ spent, budget }: BudgetBarProps) => {
|
||||
const ratio = budgetMicros > 0 ? spentMicros / budgetMicros : 0;
|
||||
const percentage = Math.min(ratio * 100, 100);
|
||||
|
||||
const fillColor =
|
||||
ratio > 0.9
|
||||
? 'bg-error-solid'
|
||||
: ratio > 0.7
|
||||
? 'bg-warning-solid'
|
||||
: 'bg-brand-solid';
|
||||
const fillColor = ratio > 0.9 ? "bg-error-solid" : ratio > 0.7 ? "bg-warning-solid" : "bg-brand-solid";
|
||||
|
||||
const spentDisplay = spent ? formatCurrency(spent.amountMicros, spent.currencyCode) : '--';
|
||||
const budgetDisplay = budget ? formatCurrency(budget.amountMicros, budget.currencyCode) : '--';
|
||||
const spentDisplay = spent ? formatCurrency(spent.amountMicros, spent.currencyCode) : "--";
|
||||
const budgetDisplay = budget ? formatCurrency(budget.amountMicros, budget.currencyCode) : "--";
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -36,11 +31,8 @@ export const BudgetBar = ({ spent, budget }: BudgetBarProps) => {
|
||||
{spentDisplay} / {budgetDisplay}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-tertiary overflow-hidden">
|
||||
<div
|
||||
className={cx('h-full rounded-full transition-all duration-300', fillColor)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-tertiary">
|
||||
<div className={cx("h-full rounded-full transition-all duration-300", fillColor)} style={{ width: `${percentage}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { faPenToSquare } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Campaign, CampaignStatus } from '@/types/entities';
|
||||
import { useState } from "react";
|
||||
import { faPenToSquare } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { SlideoutMenu } from "@/components/application/slideout-menus/slideout-menu";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
import { Input } from "@/components/base/input/input";
|
||||
import { Select } from "@/components/base/select/select";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { faIcon } from "@/lib/icon-wrapper";
|
||||
import { notify } from "@/lib/toast";
|
||||
import type { Campaign, CampaignStatus } from "@/types/entities";
|
||||
|
||||
const PenIcon = faIcon(faPenToSquare);
|
||||
|
||||
@@ -19,28 +19,28 @@ type CampaignEditSlideoutProps = {
|
||||
};
|
||||
|
||||
const statusItems = [
|
||||
{ id: 'DRAFT' as const, label: 'Draft' },
|
||||
{ id: 'ACTIVE' as const, label: 'Active' },
|
||||
{ id: 'PAUSED' as const, label: 'Paused' },
|
||||
{ id: 'COMPLETED' as const, label: 'Completed' },
|
||||
{ id: "DRAFT" as const, label: "Draft" },
|
||||
{ id: "ACTIVE" as const, label: "Active" },
|
||||
{ id: "PAUSED" as const, label: "Paused" },
|
||||
{ id: "COMPLETED" as const, label: "Completed" },
|
||||
];
|
||||
|
||||
const formatDateForInput = (dateStr: string | null): string => {
|
||||
if (!dateStr) return '';
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
return new Date(dateStr).toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const budgetToDisplay = (campaign: Campaign): string => {
|
||||
if (!campaign.budget) return '';
|
||||
if (!campaign.budget) return "";
|
||||
return String(Math.round(campaign.budget.amountMicros / 1_000_000));
|
||||
};
|
||||
|
||||
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 [budget, setBudget] = useState(budgetToDisplay(campaign));
|
||||
const [startDate, setStartDate] = useState(formatDateForInput(campaign.startDate));
|
||||
@@ -65,7 +65,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
||||
? {
|
||||
budget: {
|
||||
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?.();
|
||||
close();
|
||||
} catch (err) {
|
||||
// apiClient.graphql already toasts on error
|
||||
console.error('Failed to update campaign:', err);
|
||||
console.error("Failed to update campaign:", err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -104,12 +104,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
||||
|
||||
<SlideoutMenu.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Campaign Name"
|
||||
placeholder="Enter campaign name"
|
||||
value={campaignName}
|
||||
onChange={setCampaignName}
|
||||
/>
|
||||
<Input label="Campaign Name" placeholder="Enter campaign name" value={campaignName} onChange={setCampaignName} />
|
||||
|
||||
<Select
|
||||
label="Status"
|
||||
@@ -121,27 +116,11 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
label="Budget (INR)"
|
||||
placeholder="e.g. 50000"
|
||||
type="number"
|
||||
value={budget}
|
||||
onChange={setBudget}
|
||||
/>
|
||||
<Input label="Budget (INR)" placeholder="e.g. 50000" type="number" value={budget} onChange={setBudget} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Start Date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
/>
|
||||
<Input
|
||||
label="End Date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
/>
|
||||
<Input label="Start Date" type="date" value={startDate} onChange={setStartDate} />
|
||||
<Input label="End Date" type="date" value={endDate} onChange={setEndDate} />
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Content>
|
||||
@@ -151,14 +130,8 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
||||
<Button size="md" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isLoading={isSaving}
|
||||
showTextWhileLoading
|
||||
onClick={() => handleSave(close)}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
<Button size="md" color="primary" isLoading={isSaving} showTextWhileLoading onClick={() => handleSave(close)}>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</SlideoutMenu.Footer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Campaign, Lead } from '@/types/entities';
|
||||
import type { Campaign, Lead } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface ConversionFunnelProps {
|
||||
campaign: Campaign;
|
||||
@@ -15,14 +15,14 @@ type FunnelStep = {
|
||||
export const ConversionFunnel = ({ campaign, leads }: ConversionFunnelProps) => {
|
||||
const leadCount = campaign.leadCount ?? 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 steps: FunnelStep[] = [
|
||||
{ label: 'Leads', count: leadCount, color: 'bg-brand-solid' },
|
||||
{ label: 'Contacted', count: contactedCount, color: 'bg-brand-primary' },
|
||||
{ label: 'Appointment Set', count: appointmentCount, color: 'bg-brand-primary_alt' },
|
||||
{ label: 'Converted', count: convertedCount, color: 'bg-success-solid' },
|
||||
{ label: "Leads", count: leadCount, color: "bg-brand-solid" },
|
||||
{ label: "Contacted", count: contactedCount, color: "bg-brand-primary" },
|
||||
{ label: "Appointment Set", count: appointmentCount, color: "bg-brand-primary_alt" },
|
||||
{ label: "Converted", count: convertedCount, color: "bg-success-solid" },
|
||||
];
|
||||
|
||||
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">
|
||||
<span className="w-24 shrink-0 text-xs text-tertiary">{step.label}</span>
|
||||
<div className="flex-1">
|
||||
<div className="h-5 rounded bg-secondary overflow-hidden">
|
||||
<div className="h-5 overflow-hidden rounded bg-secondary">
|
||||
<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)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-10 shrink-0 text-right text-xs font-bold text-primary">
|
||||
{step.count}
|
||||
</span>
|
||||
<span className="w-10 shrink-0 text-right text-xs font-bold text-primary">{step.count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Campaign, Lead, HealthStatus } from '@/types/entities';
|
||||
import type { Campaign, HealthStatus, Lead } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface HealthIndicatorProps {
|
||||
campaign: Campaign;
|
||||
@@ -7,8 +7,8 @@ interface HealthIndicatorProps {
|
||||
}
|
||||
|
||||
const computeHealth = (campaign: Campaign, _leads: Lead[]): { status: HealthStatus; reason: string } => {
|
||||
if (campaign.campaignStatus === 'PAUSED') {
|
||||
return { status: 'UNHEALTHY', reason: 'Campaign is paused' };
|
||||
if (campaign.campaignStatus === "PAUSED") {
|
||||
return { status: "UNHEALTHY", reason: "Campaign is paused" };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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) {
|
||||
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 }> = {
|
||||
HEALTHY: { dot: 'bg-success-solid', text: 'text-success-primary', label: 'Healthy' },
|
||||
WARNING: { dot: 'bg-warning-solid', text: 'text-warning-primary', label: 'Warning' },
|
||||
UNHEALTHY: { dot: 'bg-error-solid', text: 'text-error-primary', label: 'Unhealthy' },
|
||||
HEALTHY: { dot: "bg-success-solid", text: "text-success-primary", label: "Healthy" },
|
||||
WARNING: { dot: "bg-warning-solid", text: "text-warning-primary", label: "Warning" },
|
||||
UNHEALTHY: { dot: "bg-error-solid", text: "text-error-primary", label: "Unhealthy" },
|
||||
};
|
||||
|
||||
export const HealthIndicator = ({ campaign, leads }: HealthIndicatorProps) => {
|
||||
@@ -38,10 +38,10 @@ export const HealthIndicator = ({ campaign, leads }: HealthIndicatorProps) => {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className={cx('font-bold', style.text)}>{style.label}</span>
|
||||
{' \u2014 '}
|
||||
<span className={cx("font-bold", style.text)}>{style.label}</span>
|
||||
{" \u2014 "}
|
||||
{reason}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
import { formatCurrency } from '@/lib/format';
|
||||
import type { Campaign } from '@/types/entities';
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import type { Campaign } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface KpiStripProps {
|
||||
campaign: Campaign;
|
||||
@@ -19,50 +19,50 @@ export const KpiStrip = ({ campaign }: KpiStripProps) => {
|
||||
const convertedCount = campaign.convertedCount ?? 0;
|
||||
const spentMicros = campaign.amountSpent?.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 conversionRate = leadCount > 0 ? ((convertedCount / leadCount) * 100).toFixed(1) : '0.0';
|
||||
const budgetPercent = budgetMicros > 0 ? ((spentMicros / budgetMicros) * 100).toFixed(0) : '--';
|
||||
const costPerLead = leadCount > 0 ? formatCurrency(spentMicros / leadCount, currencyCode) : '--';
|
||||
const cac = convertedCount > 0 ? formatCurrency(spentMicros / convertedCount, currencyCode) : '--';
|
||||
const contactRate = leadCount > 0 ? ((contactedCount / 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 costPerLead = leadCount > 0 ? formatCurrency(spentMicros / leadCount, currencyCode) : "--";
|
||||
const cac = convertedCount > 0 ? formatCurrency(spentMicros / convertedCount, currencyCode) : "--";
|
||||
|
||||
const items: KpiItem[] = [
|
||||
{
|
||||
label: 'Total Leads',
|
||||
label: "Total Leads",
|
||||
value: String(leadCount),
|
||||
subText: `${campaign.impressionCount ?? 0} impressions`,
|
||||
subColor: 'text-tertiary',
|
||||
subColor: "text-tertiary",
|
||||
},
|
||||
{
|
||||
label: 'Contacted',
|
||||
label: "Contacted",
|
||||
value: String(contactedCount),
|
||||
subText: `${contactRate}% contact rate`,
|
||||
subColor: 'text-success-primary',
|
||||
subColor: "text-success-primary",
|
||||
},
|
||||
{
|
||||
label: 'Converted',
|
||||
label: "Converted",
|
||||
value: String(convertedCount),
|
||||
subText: `${conversionRate}% conversion`,
|
||||
subColor: 'text-success-primary',
|
||||
subColor: "text-success-primary",
|
||||
},
|
||||
{
|
||||
label: 'Spent',
|
||||
label: "Spent",
|
||||
value: formatCurrency(spentMicros, currencyCode),
|
||||
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,
|
||||
subText: 'avg per lead',
|
||||
subColor: 'text-tertiary',
|
||||
subText: "avg per lead",
|
||||
subColor: "text-tertiary",
|
||||
},
|
||||
{
|
||||
label: 'CAC',
|
||||
label: "CAC",
|
||||
value: cac,
|
||||
subText: 'per conversion',
|
||||
subColor: 'text-tertiary',
|
||||
subText: "per conversion",
|
||||
subColor: "text-tertiary",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -72,15 +72,15 @@ export const KpiStrip = ({ campaign }: KpiStripProps) => {
|
||||
<div
|
||||
key={item.label}
|
||||
className={cx(
|
||||
'flex flex-1 flex-col justify-center px-4',
|
||||
index === 0 && 'pl-0',
|
||||
index === items.length - 1 && 'pr-0',
|
||||
index < items.length - 1 && 'border-r border-tertiary',
|
||||
"flex flex-1 flex-col justify-center px-4",
|
||||
index === 0 && "pl-0",
|
||||
index === items.length - 1 && "pr-0",
|
||||
index < items.length - 1 && "border-r border-tertiary",
|
||||
)}
|
||||
>
|
||||
<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={cx('mt-0.5 text-xs', item.subColor)}>{item.subText}</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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Lead } from '@/types/entities';
|
||||
import type { Lead } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface SourceBreakdownProps {
|
||||
leads: Lead[];
|
||||
}
|
||||
|
||||
const sourceColors: Record<string, string> = {
|
||||
FACEBOOK_AD: 'bg-brand-solid',
|
||||
GOOGLE_AD: 'bg-success-solid',
|
||||
INSTAGRAM: 'bg-error-solid',
|
||||
GOOGLE_MY_BUSINESS: 'bg-warning-solid',
|
||||
WEBSITE: 'bg-fg-brand-primary',
|
||||
REFERRAL: 'bg-fg-tertiary',
|
||||
WHATSAPP: 'bg-success-solid',
|
||||
WALK_IN: 'bg-fg-quaternary',
|
||||
PHONE: 'bg-fg-secondary',
|
||||
OTHER: 'bg-fg-disabled',
|
||||
FACEBOOK_AD: "bg-brand-solid",
|
||||
GOOGLE_AD: "bg-success-solid",
|
||||
INSTAGRAM: "bg-error-solid",
|
||||
GOOGLE_MY_BUSINESS: "bg-warning-solid",
|
||||
WEBSITE: "bg-fg-brand-primary",
|
||||
REFERRAL: "bg-fg-tertiary",
|
||||
WHATSAPP: "bg-success-solid",
|
||||
WALK_IN: "bg-fg-quaternary",
|
||||
PHONE: "bg-fg-secondary",
|
||||
OTHER: "bg-fg-disabled",
|
||||
};
|
||||
|
||||
const sourceLabel = (source: string): string =>
|
||||
source.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const sourceLabel = (source: string): string => source.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
export const SourceBreakdown = ({ leads }: SourceBreakdownProps) => {
|
||||
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;
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -43,23 +42,16 @@ export const SourceBreakdown = ({ leads }: SourceBreakdownProps) => {
|
||||
const widthPercent = (count / maxCount) * 100;
|
||||
return (
|
||||
<div key={source} className="flex items-center gap-3">
|
||||
<span className="w-28 shrink-0 truncate text-xs text-tertiary">
|
||||
{sourceLabel(source)}
|
||||
</span>
|
||||
<span className="w-28 shrink-0 truncate text-xs text-tertiary">{sourceLabel(source)}</span>
|
||||
<div className="flex-1">
|
||||
<div className="h-4 rounded bg-secondary overflow-hidden">
|
||||
<div className="h-4 overflow-hidden rounded bg-secondary">
|
||||
<div
|
||||
className={cx(
|
||||
'h-full rounded transition-all duration-300',
|
||||
sourceColors[source] ?? 'bg-fg-disabled',
|
||||
)}
|
||||
className={cx("h-full rounded transition-all duration-300", sourceColors[source] ?? "bg-fg-disabled")}
|
||||
style={{ width: `${Math.max(widthPercent, 4)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-8 shrink-0 text-right text-xs font-bold text-primary">
|
||||
{count}
|
||||
</span>
|
||||
<span className="w-8 shrink-0 text-right text-xs font-bold text-primary">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Table, TableCard } from '@/components/application/table/table';
|
||||
import { getInitials } from '@/lib/format';
|
||||
import type { Call } from '@/types/entities';
|
||||
import { useMemo } from "react";
|
||||
import { faUserHeadset } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link } from "react-router";
|
||||
import { Table, TableCard } from "@/components/application/table/table";
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import { Badge } from "@/components/base/badges/badges";
|
||||
import { getInitials } from "@/lib/format";
|
||||
import type { Call } from "@/types/entities";
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
@@ -16,7 +16,7 @@ const formatDuration = (seconds: 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)}%`;
|
||||
};
|
||||
|
||||
@@ -28,37 +28,44 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
||||
const agents = useMemo(() => {
|
||||
const agentMap = new Map<string, Call[]>();
|
||||
for (const call of calls) {
|
||||
const agent = call.agentName ?? 'Unknown';
|
||||
const agent = call.agentName ?? "Unknown";
|
||||
if (!agentMap.has(agent)) agentMap.set(agent, []);
|
||||
agentMap.get(agent)!.push(call);
|
||||
}
|
||||
|
||||
return Array.from(agentMap.entries()).map(([name, agentCalls]) => {
|
||||
const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||
const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||
const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
const total = agentCalls.length;
|
||||
const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0);
|
||||
const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
||||
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
||||
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
||||
const nameParts = name.split(' ');
|
||||
return Array.from(agentMap.entries())
|
||||
.map(([name, agentCalls]) => {
|
||||
const inbound = agentCalls.filter((c) => c.callDirection === "INBOUND").length;
|
||||
const outbound = agentCalls.filter((c) => c.callDirection === "OUTBOUND").length;
|
||||
const missed = agentCalls.filter((c) => c.callStatus === "MISSED").length;
|
||||
const total = agentCalls.length;
|
||||
const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0);
|
||||
const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
||||
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
||||
const booked = agentCalls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length;
|
||||
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
||||
const nameParts = name.split(" ");
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''),
|
||||
inbound, outbound, missed, total, avgHandle, conversion,
|
||||
};
|
||||
}).sort((a, b) => b.total - a.total);
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
initials: getInitials(nameParts[0] ?? "", nameParts[1] ?? ""),
|
||||
inbound,
|
||||
outbound,
|
||||
missed,
|
||||
total,
|
||||
avgHandle,
|
||||
conversion,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.total - a.total);
|
||||
}, [calls]);
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<TableCard.Root size="sm">
|
||||
<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" />
|
||||
<p className="text-sm text-tertiary">No agent data available</p>
|
||||
</div>
|
||||
@@ -85,18 +92,32 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
||||
<Link to={`/agent/${encodeURIComponent(agent.name)}`} className="no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</Link>
|
||||
</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>
|
||||
{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><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'}>
|
||||
<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)}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faPhone,
|
||||
faPhoneArrowDownLeft,
|
||||
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';
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faCircleInfo, faPhone, faPhoneArrowDownLeft, faPhoneArrowUpRight, faPhoneMissed } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import type { Call, Lead } from "@/types/entities";
|
||||
|
||||
type KpiCardProps = {
|
||||
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}`}>
|
||||
<FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||
{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 => {
|
||||
if (isNaN(value) || !isFinite(value)) return '0%';
|
||||
if (isNaN(value) || !isFinite(value)) return "0%";
|
||||
return `${Math.round(value)}%`;
|
||||
};
|
||||
|
||||
const formatMinutes = (minutes: number | null): string => {
|
||||
if (minutes === null) return '—';
|
||||
if (minutes === null) return "—";
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
@@ -73,46 +67,96 @@ interface DashboardKpiProps {
|
||||
|
||||
export const DashboardKpi = ({ calls, leads }: DashboardKpiProps) => {
|
||||
const totalCalls = calls.length;
|
||||
const inboundCalls = calls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||
const outboundCalls = calls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||
const missedCalls = calls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
const inboundCalls = calls.filter((c) => c.callDirection === "INBOUND").length;
|
||||
const outboundCalls = calls.filter((c) => c.callDirection === "OUTBOUND").length;
|
||||
const missedCalls = calls.filter((c) => c.callStatus === "MISSED").length;
|
||||
|
||||
const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt);
|
||||
const avgResponseTime = leadsWithResponse.length > 0
|
||||
? Math.round(leadsWithResponse.reduce((sum, l) => {
|
||||
const diff = Math.abs(new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000;
|
||||
return sum + diff;
|
||||
}, 0) / leadsWithResponse.length)
|
||||
: null;
|
||||
const avgResponseTime =
|
||||
leadsWithResponse.length > 0
|
||||
? Math.round(
|
||||
leadsWithResponse.reduce((sum, l) => {
|
||||
const diff = Math.abs(new Date(l.firstContactedAt!).getTime() - new Date(l.createdAt!).getTime()) / 60000;
|
||||
return sum + diff;
|
||||
}, 0) / leadsWithResponse.length,
|
||||
)
|
||||
: null;
|
||||
|
||||
const missedCallsList = calls.filter((c) => c.callStatus === 'MISSED' && c.startedAt);
|
||||
const missedCallbackTime = missedCallsList.length > 0
|
||||
? Math.round(missedCallsList.reduce((sum, c) => sum + (Date.now() - new Date(c.startedAt!).getTime()) / 60000, 0) / missedCallsList.length)
|
||||
: null;
|
||||
const missedCallsList = calls.filter((c) => c.callStatus === "MISSED" && c.startedAt);
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const renderTime = Date.now();
|
||||
const missedCallbackTime =
|
||||
missedCallsList.length > 0
|
||||
? Math.round(missedCallsList.reduce((sum, c) => sum + (renderTime - new Date(c.startedAt!).getTime()) / 60000, 0) / missedCallsList.length)
|
||||
: null;
|
||||
|
||||
const callToAppt = totalCalls > 0
|
||||
? (calls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length / totalCalls) * 100
|
||||
: 0;
|
||||
const callToAppt = totalCalls > 0 ? (calls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length / totalCalls) * 100 : 0;
|
||||
|
||||
const leadToAppt = leads.length > 0
|
||||
? (leads.filter((l) => l.leadStatus === 'APPOINTMENT_SET' || l.leadStatus === 'CONVERTED').length / leads.length) * 100
|
||||
: 0;
|
||||
const leadToAppt =
|
||||
leads.length > 0 ? (leads.filter((l) => l.leadStatus === "APPOINTMENT_SET" || l.leadStatus === "CONVERTED").length / leads.length) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-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 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"
|
||||
<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
|
||||
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}
|
||||
tooltip="Inbound calls that were not answered by any agent" />
|
||||
tooltip="Inbound calls that were not answered by any agent"
|
||||
/>
|
||||
</div>
|
||||
<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 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" />
|
||||
<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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhoneMissed } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||
import type { Call } from '@/types/entities';
|
||||
import { useMemo } from "react";
|
||||
import { faPhoneMissed } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Badge } from "@/components/base/badges/badges";
|
||||
import { ClickToCallButton } from "@/components/call-desk/click-to-call-button";
|
||||
import type { Call } from "@/types/entities";
|
||||
|
||||
const getTimeSince = (dateStr: string | null): string => {
|
||||
if (!dateStr) return '—';
|
||||
if (!dateStr) return "—";
|
||||
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`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
@@ -22,7 +22,7 @@ interface MissedQueueProps {
|
||||
export const MissedQueue = ({ calls }: MissedQueueProps) => {
|
||||
const missedCalls = useMemo(() => {
|
||||
return calls
|
||||
.filter((c) => c.callStatus === 'MISSED')
|
||||
.filter((c) => c.callStatus === "MISSED")
|
||||
.sort((a, b) => {
|
||||
const dateA = a.startedAt ? new Date(a.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>
|
||||
</div>
|
||||
{missedCalls.length > 0 && (
|
||||
<Badge size="sm" color="error">{missedCalls.length}</Badge>
|
||||
<Badge size="sm" color="error">
|
||||
{missedCalls.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[500px] overflow-y-auto">
|
||||
{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" />
|
||||
<p className="text-sm text-tertiary">No missed calls</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary">
|
||||
{missedCalls.map((call) => {
|
||||
const phone = call.callerNumber?.[0]?.number ?? '';
|
||||
const display = phone ? `+91 ${phone}` : 'Unknown';
|
||||
const phone = call.callerNumber?.[0]?.number ?? "";
|
||||
const display = phone ? `+91 ${phone}` : "Unknown";
|
||||
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">
|
||||
<span className="text-sm font-medium text-primary">{display}</span>
|
||||
<span className="text-xs text-tertiary">{getTimeSince(call.startedAt)}</span>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FC, ReactNode, Ref } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { type FC, type ReactNode, type Ref, isValidElement } from "react";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
import { isReactComponent } from "@/utils/is-react-component";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { SVGProps } from "react";
|
||||
import { useId } from "react";
|
||||
import { type SVGProps, useId } from "react";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
export const UntitledLogoMinimal = (props: SVGProps<SVGSVGElement>) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { HTMLAttributes, SVGProps } from "react";
|
||||
import { useId } from "react";
|
||||
import { type HTMLAttributes, type SVGProps, useId } from "react";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const getStarProgress = (starPosition: number, rating: number, maxRating: number = 5) => {
|
||||
// Ensure rating is between 0 and 5
|
||||
const clampedRating = Math.min(Math.max(rating, 0), maxRating);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faGear, faCopy, faLink } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { useState } from "react";
|
||||
import { faCopy, faGear, faLink } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { SlideoutMenu } from "@/components/application/slideout-menus/slideout-menu";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
import { Input } from "@/components/base/input/input";
|
||||
import { faIcon } from "@/lib/icon-wrapper";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
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: IntegrationType;
|
||||
@@ -36,39 +36,39 @@ type FieldDef = {
|
||||
|
||||
const getFieldsForType = (integration: IntegrationConfig): FieldDef[] => {
|
||||
switch (integration.type) {
|
||||
case 'ozonetel':
|
||||
case "ozonetel":
|
||||
return [
|
||||
{ key: 'account', label: 'Account ID', placeholder: 'e.g. global_healthx' },
|
||||
{ key: 'apiKey', label: 'API Key', placeholder: 'Enter API key', type: 'password' },
|
||||
{ key: 'agentId', label: 'Agent ID', placeholder: 'e.g. global' },
|
||||
{ key: 'sipId', label: 'SIP ID / Extension', placeholder: 'e.g. 523590' },
|
||||
{ key: 'campaign', label: 'Campaign Name', placeholder: 'e.g. Inbound_918041763265' },
|
||||
{ key: "account", label: "Account ID", placeholder: "e.g. global_healthx" },
|
||||
{ key: "apiKey", label: "API Key", placeholder: "Enter API key", type: "password" },
|
||||
{ key: "agentId", label: "Agent ID", placeholder: "e.g. global" },
|
||||
{ key: "sipId", label: "SIP ID / Extension", placeholder: "e.g. 523590" },
|
||||
{ key: "campaign", label: "Campaign Name", placeholder: "e.g. Inbound_918041763265" },
|
||||
];
|
||||
case 'whatsapp':
|
||||
case "whatsapp":
|
||||
return [
|
||||
{ key: 'apiKey', label: 'API Key', placeholder: 'Enter WhatsApp API key', type: 'password' },
|
||||
{ key: 'phoneNumberId', label: 'Phone Number ID', placeholder: 'e.g. 123456789012345' },
|
||||
{ key: "apiKey", label: "API Key", placeholder: "Enter WhatsApp API key", type: "password" },
|
||||
{ key: "phoneNumberId", label: "Phone Number ID", placeholder: "e.g. 123456789012345" },
|
||||
];
|
||||
case 'facebook':
|
||||
case 'google':
|
||||
case 'instagram':
|
||||
case "facebook":
|
||||
case "google":
|
||||
case "instagram":
|
||||
return [];
|
||||
case 'website':
|
||||
case "website":
|
||||
return [
|
||||
{
|
||||
key: 'webhookUrl',
|
||||
label: 'Webhook URL',
|
||||
placeholder: '',
|
||||
key: "webhookUrl",
|
||||
label: "Webhook URL",
|
||||
placeholder: "",
|
||||
readOnly: true,
|
||||
copyable: true,
|
||||
},
|
||||
];
|
||||
case 'email':
|
||||
case "email":
|
||||
return [
|
||||
{ key: 'smtpHost', label: 'SMTP Host', placeholder: 'e.g. smtp.gmail.com' },
|
||||
{ key: 'smtpPort', label: 'Port', placeholder: 'e.g. 587' },
|
||||
{ key: 'smtpUser', label: 'Username', placeholder: 'e.g. noreply@clinic.com' },
|
||||
{ key: 'smtpPassword', label: 'Password', placeholder: 'Enter SMTP password', type: 'password' },
|
||||
{ key: "smtpHost", label: "SMTP Host", placeholder: "e.g. smtp.gmail.com" },
|
||||
{ key: "smtpPort", label: "Port", placeholder: "e.g. 587" },
|
||||
{ key: "smtpUser", label: "Username", placeholder: "e.g. noreply@clinic.com" },
|
||||
{ key: "smtpPassword", label: "Password", placeholder: "Enter SMTP password", type: "password" },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
@@ -80,25 +80,25 @@ const getInitialValues = (integration: IntegrationConfig): Record<string, string
|
||||
const detailMap = new Map(integration.details.map((d) => [d.label, d.value]));
|
||||
|
||||
switch (integration.type) {
|
||||
case 'ozonetel':
|
||||
values.account = detailMap.get('Account') ?? '';
|
||||
values.apiKey = '';
|
||||
values.agentId = detailMap.get('Agent ID') ?? '';
|
||||
values.sipId = detailMap.get('SIP Extension') ?? '';
|
||||
values.campaign = detailMap.get('Inbound Campaign') ?? '';
|
||||
case "ozonetel":
|
||||
values.account = detailMap.get("Account") ?? "";
|
||||
values.apiKey = "";
|
||||
values.agentId = detailMap.get("Agent ID") ?? "";
|
||||
values.sipId = detailMap.get("SIP Extension") ?? "";
|
||||
values.campaign = detailMap.get("Inbound Campaign") ?? "";
|
||||
break;
|
||||
case 'whatsapp':
|
||||
values.apiKey = '';
|
||||
values.phoneNumberId = '';
|
||||
case "whatsapp":
|
||||
values.apiKey = "";
|
||||
values.phoneNumberId = "";
|
||||
break;
|
||||
case 'website':
|
||||
values.webhookUrl = integration.webhookUrl ?? '';
|
||||
case "website":
|
||||
values.webhookUrl = integration.webhookUrl ?? "";
|
||||
break;
|
||||
case 'email':
|
||||
values.smtpHost = '';
|
||||
values.smtpPort = '587';
|
||||
values.smtpUser = '';
|
||||
values.smtpPassword = '';
|
||||
case "email":
|
||||
values.smtpHost = "";
|
||||
values.smtpPort = "587";
|
||||
values.smtpUser = "";
|
||||
values.smtpPassword = "";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -107,8 +107,7 @@ const getInitialValues = (integration: IntegrationConfig): Record<string, string
|
||||
return values;
|
||||
};
|
||||
|
||||
const isOAuthType = (type: IntegrationType): boolean =>
|
||||
type === 'facebook' || type === 'google' || type === 'instagram';
|
||||
const isOAuthType = (type: IntegrationType): boolean => type === "facebook" || type === "google" || type === "instagram";
|
||||
|
||||
export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: IntegrationEditSlideoutProps) => {
|
||||
const fields = getFieldsForType(integration);
|
||||
@@ -120,16 +119,16 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
navigator.clipboard.writeText(value);
|
||||
notify.success('Copied', 'Value copied to clipboard');
|
||||
notify.success("Copied", "Value copied to clipboard");
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
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 (
|
||||
@@ -161,12 +160,7 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
|
||||
Authorize Helix Engage to access your {integration.name} account to import leads automatically.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
iconLeading={faIcon(faLink)}
|
||||
onClick={handleOAuthConnect}
|
||||
>
|
||||
<Button size="md" color="primary" iconLeading={faIcon(faLink)} onClick={handleOAuthConnect}>
|
||||
Connect {integration.name}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -177,14 +171,12 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-secondary">{field.label}</label>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-secondary p-3">
|
||||
<code className="flex-1 truncate text-xs text-secondary">
|
||||
{values[field.key] || '\u2014'}
|
||||
</code>
|
||||
<code className="flex-1 truncate text-xs text-secondary">{values[field.key] || "\u2014"}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={faIcon(faCopy)}
|
||||
onClick={() => handleCopy(values[field.key] ?? '')}
|
||||
onClick={() => handleCopy(values[field.key] ?? "")}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
@@ -195,7 +187,7 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
type={field.type}
|
||||
value={values[field.key] ?? ''}
|
||||
value={values[field.key] ?? ""}
|
||||
onChange={(value) => updateValue(field.key, value)}
|
||||
isDisabled={field.readOnly}
|
||||
/>
|
||||
@@ -212,11 +204,7 @@ export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: I
|
||||
Cancel
|
||||
</Button>
|
||||
{!isOAuthType(integration.type) && (
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
onClick={() => handleSave(close)}
|
||||
>
|
||||
<Button size="md" color="primary" onClick={() => handleSave(close)}>
|
||||
Save Configuration
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate, Outlet } from 'react-router';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { Navigate, Outlet } from "react-router";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
|
||||
export const AuthGuard = () => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { LeadWorkspacePage } from '@/pages/lead-workspace';
|
||||
import { TeamDashboardPage } from '@/pages/team-dashboard';
|
||||
import { CallDeskPage } from '@/pages/call-desk';
|
||||
import { CallDeskPage } from "@/pages/call-desk";
|
||||
import { LeadWorkspacePage } from "@/pages/lead-workspace";
|
||||
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
|
||||
export const RoleRouter = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
switch (user.role) {
|
||||
case 'admin':
|
||||
case "admin":
|
||||
return <TeamDashboardPage />;
|
||||
case 'cc-agent':
|
||||
case "cc-agent":
|
||||
return <CallDeskPage />;
|
||||
default:
|
||||
return <LeadWorkspacePage />;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
import { daysAgoFromNow } from '@/lib/format';
|
||||
import type { Lead } from '@/types/entities';
|
||||
import { daysAgoFromNow } from "@/lib/format";
|
||||
import type { Lead } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface AgingWidgetProps {
|
||||
leads: Lead[];
|
||||
@@ -26,21 +26,21 @@ export const AgingWidget = ({ leads }: AgingWidgetProps) => {
|
||||
|
||||
const brackets: AgingBracket[] = [
|
||||
{
|
||||
label: 'Fresh (<2 days)',
|
||||
color: 'text-success-primary',
|
||||
barColor: 'bg-success-solid',
|
||||
label: "Fresh (<2 days)",
|
||||
color: "text-success-primary",
|
||||
barColor: "bg-success-solid",
|
||||
count: freshCount,
|
||||
},
|
||||
{
|
||||
label: 'Warm (2-5 days)',
|
||||
color: 'text-warning-primary',
|
||||
barColor: 'bg-warning-solid',
|
||||
label: "Warm (2-5 days)",
|
||||
color: "text-warning-primary",
|
||||
barColor: "bg-warning-solid",
|
||||
count: warmCount,
|
||||
},
|
||||
{
|
||||
label: 'Cold (>5 days)',
|
||||
color: 'text-error-primary',
|
||||
barColor: 'bg-error-solid',
|
||||
label: "Cold (>5 days)",
|
||||
color: "text-error-primary",
|
||||
barColor: "bg-error-solid",
|
||||
count: coldCount,
|
||||
},
|
||||
];
|
||||
@@ -52,14 +52,12 @@ export const AgingWidget = ({ leads }: AgingWidgetProps) => {
|
||||
{brackets.map((bracket) => (
|
||||
<div key={bracket.label}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className={cx('text-xs', bracket.color)}>{bracket.label}</span>
|
||||
<span className={cx('text-sm font-bold', bracket.color)}>
|
||||
{bracket.count}
|
||||
</span>
|
||||
<span className={cx("text-xs", bracket.color)}>{bracket.label}</span>
|
||||
<span className={cx("text-sm font-bold", bracket.color)}>{bracket.count}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-tertiary">
|
||||
<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}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
import { daysAgoFromNow } from '@/lib/format';
|
||||
import type { Lead } from '@/types/entities';
|
||||
import { daysAgoFromNow } from "@/lib/format";
|
||||
import type { Lead } from "@/types/entities";
|
||||
|
||||
interface AlertsWidgetProps {
|
||||
leads: Lead[];
|
||||
}
|
||||
|
||||
export const AlertsWidget = ({ leads }: AlertsWidgetProps) => {
|
||||
const agingCount = leads.filter(
|
||||
(l) =>
|
||||
l.leadStatus === 'NEW' &&
|
||||
l.createdAt !== null &&
|
||||
daysAgoFromNow(l.createdAt) > 5,
|
||||
).length;
|
||||
const agingCount = leads.filter((l) => l.leadStatus === "NEW" && l.createdAt !== null && daysAgoFromNow(l.createdAt) > 5).length;
|
||||
|
||||
if (agingCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-error-subtle bg-error-primary p-4">
|
||||
<p className="text-xs font-bold text-error-primary">
|
||||
{agingCount} leads aging > 5 days
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-error-primary opacity-80">
|
||||
These leads haven't been contacted and are at risk of going cold.
|
||||
</p>
|
||||
<div className="border-error-subtle rounded-xl border bg-error-primary p-4">
|
||||
<p className="text-xs font-bold text-error-primary">{agingCount} leads aging > 5 days</p>
|
||||
<p className="mt-1 text-xs text-error-primary opacity-80">These leads haven't been contacted and are at risk of going cold.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,39 +9,23 @@ interface BulkActionBarProps {
|
||||
export const BulkActionBar = ({ selectedCount, onAssign, onWhatsApp, onMarkSpam, onDeselect }: BulkActionBarProps) => {
|
||||
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 (
|
||||
<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>
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAssign}
|
||||
className={`${buttonBase} bg-white/20 text-white hover:bg-white/30`}
|
||||
>
|
||||
<button type="button" onClick={onAssign} className={`${buttonBase} bg-white/20 text-white hover:bg-white/30`}>
|
||||
Assign to Call Center
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onWhatsApp}
|
||||
className={`${buttonBase} bg-success-solid text-white hover:bg-success-solid/90`}
|
||||
>
|
||||
<button type="button" onClick={onWhatsApp} className={`${buttonBase} bg-success-solid text-white hover:bg-success-solid/90`}>
|
||||
Send WhatsApp
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMarkSpam}
|
||||
className={`${buttonBase} bg-white/15 text-error-primary hover:bg-white/25`}
|
||||
>
|
||||
<button type="button" onClick={onMarkSpam} className={`${buttonBase} bg-white/15 text-error-primary hover:bg-white/25`}>
|
||||
Mark Spam
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeselect}
|
||||
className={`${buttonBase} bg-white/10 text-white/70 hover:bg-white/20 hover:text-white`}
|
||||
>
|
||||
<button type="button" onClick={onDeselect} className={`${buttonBase} bg-white/10 text-white/70 hover:bg-white/20 hover:text-white`}>
|
||||
Deselect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
type FilterPill = {
|
||||
key: string;
|
||||
@@ -23,7 +23,7 @@ export const FilterPills = ({ filters, onRemove, onClearAll }: FilterPillsProps)
|
||||
<span
|
||||
key={filter.key}
|
||||
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}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { formatShortDate } from '@/lib/format';
|
||||
import type { FollowUp } from '@/types/entities';
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
import { formatShortDate } from "@/lib/format";
|
||||
import type { FollowUp } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface FollowupWidgetProps {
|
||||
overdue: FollowUp[];
|
||||
@@ -26,26 +26,15 @@ export const FollowupWidget = ({ overdue, upcoming }: FollowupWidgetProps) => {
|
||||
<div
|
||||
key={item.id}
|
||||
className={cx(
|
||||
'rounded-lg border-l-2 p-3',
|
||||
isOverdue
|
||||
? 'border-l-error-solid bg-error-primary'
|
||||
: 'border-l-brand-solid bg-secondary',
|
||||
"rounded-lg border-l-2 p-3",
|
||||
isOverdue ? "border-l-error-solid bg-error-primary" : "border-l-brand-solid bg-secondary",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cx(
|
||||
'text-xs font-semibold',
|
||||
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 className={cx("text-xs font-semibold", 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>
|
||||
{(item.patientName || item.patientPhone) && (
|
||||
<p className="mt-0.5 text-xs text-tertiary">
|
||||
{item.patientName}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Lead } from '@/types/entities';
|
||||
import type { Lead } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface KpiCardsProps {
|
||||
leads: Lead[];
|
||||
@@ -14,38 +14,38 @@ type KpiCard = {
|
||||
};
|
||||
|
||||
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 contactedCount = leads.filter((l) => l.leadStatus === 'CONTACTED').length;
|
||||
const convertedCount = leads.filter((l) => l.leadStatus === 'CONVERTED').length;
|
||||
const contactedCount = leads.filter((l) => l.leadStatus === "CONTACTED").length;
|
||||
const convertedCount = leads.filter((l) => l.leadStatus === "CONVERTED").length;
|
||||
|
||||
const cards: KpiCard[] = [
|
||||
{
|
||||
label: 'New Leads Today',
|
||||
label: "New Leads Today",
|
||||
value: newCount,
|
||||
delta: '+12% vs yesterday',
|
||||
deltaColor: 'text-success-primary',
|
||||
delta: "+12% vs yesterday",
|
||||
deltaColor: "text-success-primary",
|
||||
isHero: true,
|
||||
},
|
||||
{
|
||||
label: 'Assigned to CC',
|
||||
label: "Assigned to CC",
|
||||
value: assignedCount,
|
||||
delta: '85% assigned',
|
||||
deltaColor: 'text-brand-secondary',
|
||||
delta: "85% assigned",
|
||||
deltaColor: "text-brand-secondary",
|
||||
isHero: false,
|
||||
},
|
||||
{
|
||||
label: 'Contacted',
|
||||
label: "Contacted",
|
||||
value: contactedCount,
|
||||
delta: '+8% vs yesterday',
|
||||
deltaColor: 'text-success-primary',
|
||||
delta: "+8% vs yesterday",
|
||||
deltaColor: "text-success-primary",
|
||||
isHero: false,
|
||||
},
|
||||
{
|
||||
label: 'Converted',
|
||||
label: "Converted",
|
||||
value: convertedCount,
|
||||
delta: '+3 this week',
|
||||
deltaColor: 'text-warning-primary',
|
||||
delta: "+3 this week",
|
||||
deltaColor: "text-warning-primary",
|
||||
isHero: false,
|
||||
},
|
||||
];
|
||||
@@ -56,36 +56,13 @@ export const KpiCards = ({ leads }: KpiCardsProps) => {
|
||||
<div
|
||||
key={card.label}
|
||||
className={cx(
|
||||
'rounded-2xl p-5 transition hover:shadow-md',
|
||||
card.isHero
|
||||
? 'flex-[1.3] bg-brand-solid text-white'
|
||||
: 'flex-1 border border-secondary bg-primary',
|
||||
"rounded-2xl p-5 transition hover:shadow-md",
|
||||
card.isHero ? "flex-[1.3] bg-brand-solid text-white" : "flex-1 border border-secondary bg-primary",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cx(
|
||||
'text-xs font-medium uppercase tracking-wider',
|
||||
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>
|
||||
<p className={cx("text-xs font-medium tracking-wider uppercase", 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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import type { Lead, LeadActivity, LeadActivityType } from '@/types/entities';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { SlideoutMenu } from "@/components/application/slideout-menus/slideout-menu";
|
||||
import { formatPhone, formatShortDate } from "@/lib/format";
|
||||
import type { Lead, LeadActivity, LeadActivityType } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
type LeadActivitySlideoutProps = {
|
||||
isOpen: boolean;
|
||||
@@ -17,31 +17,29 @@ type ActivityConfig = {
|
||||
};
|
||||
|
||||
const ACTIVITY_CONFIG: Record<LeadActivityType, ActivityConfig> = {
|
||||
STATUS_CHANGE: { icon: '🔄', dotClass: 'bg-brand-secondary', label: 'Status Changed' },
|
||||
CALL_MADE: { icon: '📞', dotClass: 'bg-brand-secondary', label: 'Call Made' },
|
||||
CALL_RECEIVED: { icon: '📲', dotClass: 'bg-brand-secondary', label: 'Call Received' },
|
||||
WHATSAPP_SENT: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Sent' },
|
||||
WHATSAPP_RECEIVED: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Received' },
|
||||
SMS_SENT: { icon: '✉️', dotClass: 'bg-brand-secondary', label: 'SMS Sent' },
|
||||
EMAIL_SENT: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Sent' },
|
||||
EMAIL_RECEIVED: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Received' },
|
||||
NOTE_ADDED: { icon: '📝', dotClass: 'bg-warning-solid', label: 'Note Added' },
|
||||
ASSIGNED: { icon: '📤', dotClass: 'bg-brand-secondary', label: 'Assigned' },
|
||||
APPOINTMENT_BOOKED: { icon: '📅', dotClass: 'bg-brand-secondary', label: 'Appointment Booked' },
|
||||
FOLLOW_UP_CREATED: { icon: '🔁', dotClass: 'bg-brand-secondary', label: 'Follow-up Created' },
|
||||
CONVERTED: { icon: '✅', dotClass: 'bg-success-solid', label: 'Converted' },
|
||||
MARKED_SPAM: { icon: '🚫', dotClass: 'bg-error-solid', label: 'Marked as Spam' },
|
||||
DUPLICATE_DETECTED: { icon: '🔍', dotClass: 'bg-warning-solid', label: 'Duplicate Detected' },
|
||||
STATUS_CHANGE: { icon: "🔄", dotClass: "bg-brand-secondary", label: "Status Changed" },
|
||||
CALL_MADE: { icon: "📞", dotClass: "bg-brand-secondary", label: "Call Made" },
|
||||
CALL_RECEIVED: { icon: "📲", dotClass: "bg-brand-secondary", label: "Call Received" },
|
||||
WHATSAPP_SENT: { icon: "💬", dotClass: "bg-success-solid", label: "WhatsApp Sent" },
|
||||
WHATSAPP_RECEIVED: { icon: "💬", dotClass: "bg-success-solid", label: "WhatsApp Received" },
|
||||
SMS_SENT: { icon: "✉️", dotClass: "bg-brand-secondary", label: "SMS Sent" },
|
||||
EMAIL_SENT: { icon: "📧", dotClass: "bg-brand-secondary", label: "Email Sent" },
|
||||
EMAIL_RECEIVED: { icon: "📧", dotClass: "bg-brand-secondary", label: "Email Received" },
|
||||
NOTE_ADDED: { icon: "📝", dotClass: "bg-warning-solid", label: "Note Added" },
|
||||
ASSIGNED: { icon: "📤", dotClass: "bg-brand-secondary", label: "Assigned" },
|
||||
APPOINTMENT_BOOKED: { icon: "📅", dotClass: "bg-brand-secondary", label: "Appointment Booked" },
|
||||
FOLLOW_UP_CREATED: { icon: "🔁", dotClass: "bg-brand-secondary", label: "Follow-up Created" },
|
||||
CONVERTED: { icon: "✅", dotClass: "bg-success-solid", label: "Converted" },
|
||||
MARKED_SPAM: { icon: "🚫", dotClass: "bg-error-solid", label: "Marked as Spam" },
|
||||
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 }) => (
|
||||
<span className="text-sm text-secondary">
|
||||
{previousValue && (
|
||||
<span className="mr-1 text-sm line-through text-quaternary">{previousValue}</span>
|
||||
)}
|
||||
{previousValue && newValue && '→ '}
|
||||
{previousValue && <span className="mr-1 text-sm text-quaternary line-through">{previousValue}</span>}
|
||||
{previousValue && newValue && "→ "}
|
||||
{newValue && <span className="font-medium text-brand-secondary">{newValue}</span>}
|
||||
</span>
|
||||
);
|
||||
@@ -49,45 +47,33 @@ const StatusChangeContent = ({ previousValue, newValue }: { previousValue: strin
|
||||
const ActivityItem = ({ activity, isLast }: { activity: LeadActivity; isLast: boolean }) => {
|
||||
const type = activity.activityType;
|
||||
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 (
|
||||
<div className="relative flex gap-3 pb-4">
|
||||
{/* Vertical connecting line */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-[15px] top-[36px] bottom-0 w-0.5 bg-tertiary" />
|
||||
)}
|
||||
{!isLast && <div className="absolute top-[36px] bottom-0 left-[15px] w-0.5 bg-tertiary" />}
|
||||
|
||||
{/* Dot */}
|
||||
<div
|
||||
className={cx(
|
||||
'relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full text-sm',
|
||||
config.dotClass,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div 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}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<span className="flex-1 text-sm font-semibold text-primary">
|
||||
{activity.summary ?? config.label}
|
||||
</span>
|
||||
<span className="flex-1 text-sm font-semibold text-primary">{activity.summary ?? config.label}</span>
|
||||
</div>
|
||||
|
||||
{type === 'STATUS_CHANGE' && (activity.previousValue || activity.newValue) && (
|
||||
{type === "STATUS_CHANGE" && (activity.previousValue || activity.newValue) && (
|
||||
<StatusChangeContent previousValue={activity.previousValue} newValue={activity.newValue} />
|
||||
)}
|
||||
|
||||
{type !== 'STATUS_CHANGE' && activity.newValue && (
|
||||
<p className="text-xs text-tertiary">{activity.newValue}</p>
|
||||
)}
|
||||
{type !== "STATUS_CHANGE" && activity.newValue && <p className="text-xs text-tertiary">{activity.newValue}</p>}
|
||||
|
||||
<p className="text-xs text-quaternary">
|
||||
{occurredAt}
|
||||
{activity.performedBy ? ` · by ${activity.performedBy}` : ''}
|
||||
{activity.performedBy ? ` · by ${activity.performedBy}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,9 +81,9 @@ const ActivityItem = ({ activity, isLast }: { activity: LeadActivity; isLast: bo
|
||||
};
|
||||
|
||||
export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }: LeadActivitySlideoutProps) => {
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
|
||||
const firstName = lead.contactName?.firstName ?? "";
|
||||
const lastName = lead.contactName?.lastName ?? "";
|
||||
const fullName = `${firstName} ${lastName}`.trim() || "Unknown Lead";
|
||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : null;
|
||||
const email = lead.contactEmail?.[0]?.address ?? null;
|
||||
|
||||
@@ -116,11 +102,7 @@ export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }:
|
||||
<SlideoutMenu.Header onClose={close}>
|
||||
<div className="flex flex-col gap-0.5 pr-8">
|
||||
<h2 className="text-lg font-semibold text-primary">Lead Activity — {fullName}</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
{[phone, email, lead.leadSource, lead.utmCampaign]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
<p className="text-sm text-tertiary">{[phone, email, lead.leadSource, lead.utmCampaign].filter(Boolean).join(" · ")}</p>
|
||||
</div>
|
||||
</SlideoutMenu.Header>
|
||||
|
||||
@@ -134,11 +116,7 @@ export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }:
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{filteredActivities.map((activity, idx) => (
|
||||
<ActivityItem
|
||||
key={activity.id}
|
||||
activity={activity}
|
||||
isLast={idx === filteredActivities.length - 1}
|
||||
/>
|
||||
<ActivityItem key={activity.id} activity={activity} isLast={idx === filteredActivities.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { LeadStatusBadge } from '@/components/shared/status-badge';
|
||||
import { SourceTag } from '@/components/shared/source-tag';
|
||||
import { AgeIndicator } from '@/components/shared/age-indicator';
|
||||
import { formatPhone, getInitials } from '@/lib/format';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Lead } from '@/types/entities';
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import { Badge } from "@/components/base/badges/badges";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
import { AgeIndicator } from "@/components/shared/age-indicator";
|
||||
import { SourceTag } from "@/components/shared/source-tag";
|
||||
import { LeadStatusBadge } from "@/components/shared/status-badge";
|
||||
import { formatPhone, getInitials } from "@/lib/format";
|
||||
import type { Lead } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface LeadCardProps {
|
||||
lead: Lead;
|
||||
@@ -19,34 +19,34 @@ interface LeadCardProps {
|
||||
}
|
||||
|
||||
const sourceLabelMap: Record<string, string> = {
|
||||
FACEBOOK_AD: 'Facebook',
|
||||
INSTAGRAM: 'Instagram',
|
||||
GOOGLE_AD: 'Google',
|
||||
GOOGLE_MY_BUSINESS: 'GMB',
|
||||
WHATSAPP: 'WhatsApp',
|
||||
WEBSITE: 'Website',
|
||||
REFERRAL: 'Referral',
|
||||
WALK_IN: 'Walk-in',
|
||||
PHONE: 'Phone',
|
||||
OTHER: 'Other',
|
||||
FACEBOOK_AD: "Facebook",
|
||||
INSTAGRAM: "Instagram",
|
||||
GOOGLE_AD: "Google",
|
||||
GOOGLE_MY_BUSINESS: "GMB",
|
||||
WHATSAPP: "WhatsApp",
|
||||
WEBSITE: "Website",
|
||||
REFERRAL: "Referral",
|
||||
WALK_IN: "Walk-in",
|
||||
PHONE: "Phone",
|
||||
OTHER: "Other",
|
||||
};
|
||||
|
||||
export const LeadCard = ({ lead, onAssign, onMessage, onMarkSpam, onMerge, onLogCall, onUpdateStatus }: LeadCardProps) => {
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const name = `${firstName} ${lastName}`.trim() || 'Unknown';
|
||||
const initials = firstName && lastName ? getInitials(firstName, lastName) : '??';
|
||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : '';
|
||||
const sourceLabel = lead.leadSource ? sourceLabelMap[lead.leadSource] ?? lead.leadSource : '';
|
||||
const firstName = lead.contactName?.firstName ?? "";
|
||||
const lastName = lead.contactName?.lastName ?? "";
|
||||
const name = `${firstName} ${lastName}`.trim() || "Unknown";
|
||||
const initials = firstName && lastName ? getInitials(firstName, lastName) : "??";
|
||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "";
|
||||
const sourceLabel = lead.leadSource ? (sourceLabelMap[lead.leadSource] ?? lead.leadSource) : "";
|
||||
const isSpam = (lead.spamScore ?? 0) >= 60;
|
||||
const isDuplicate = lead.isDuplicate === true;
|
||||
const isAssigned = lead.assignedAgent !== null && lead.leadStatus !== 'NEW';
|
||||
const isAssigned = lead.assignedAgent !== null && lead.leadStatus !== "NEW";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'flex items-center gap-4 rounded-2xl border border-secondary p-5 transition hover:shadow-md',
|
||||
isSpam ? 'bg-warning-primary' : 'bg-primary',
|
||||
"flex items-center gap-4 rounded-2xl border border-secondary p-5 transition hover:shadow-md",
|
||||
isSpam ? "bg-warning-primary" : "bg-primary",
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Lead, LeadSource } from '@/types/entities';
|
||||
import type { Lead, LeadSource } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
interface SourceGridProps {
|
||||
leads: Lead[];
|
||||
@@ -19,64 +19,63 @@ type SourceConfig = {
|
||||
|
||||
const sourceConfigs: SourceConfig[] = [
|
||||
{
|
||||
source: 'FACEBOOK_AD',
|
||||
label: 'Facebook Ads',
|
||||
icon: 'f',
|
||||
iconBg: 'bg-utility-blue-50',
|
||||
iconText: 'text-utility-blue-700',
|
||||
countColor: 'text-utility-blue-700',
|
||||
delta: '+4',
|
||||
source: "FACEBOOK_AD",
|
||||
label: "Facebook Ads",
|
||||
icon: "f",
|
||||
iconBg: "bg-utility-blue-50",
|
||||
iconText: "text-utility-blue-700",
|
||||
countColor: "text-utility-blue-700",
|
||||
delta: "+4",
|
||||
},
|
||||
{
|
||||
source: 'GOOGLE_AD',
|
||||
label: 'Google Ads',
|
||||
icon: 'G',
|
||||
iconBg: 'bg-utility-success-50',
|
||||
iconText: 'text-utility-success-700',
|
||||
countColor: 'text-utility-success-700',
|
||||
delta: '+2',
|
||||
source: "GOOGLE_AD",
|
||||
label: "Google Ads",
|
||||
icon: "G",
|
||||
iconBg: "bg-utility-success-50",
|
||||
iconText: "text-utility-success-700",
|
||||
countColor: "text-utility-success-700",
|
||||
delta: "+2",
|
||||
},
|
||||
{
|
||||
source: 'INSTAGRAM',
|
||||
label: 'Instagram',
|
||||
icon: '@',
|
||||
iconBg: 'bg-utility-pink-50',
|
||||
iconText: 'text-utility-pink-700',
|
||||
countColor: 'text-utility-pink-700',
|
||||
delta: '+1',
|
||||
source: "INSTAGRAM",
|
||||
label: "Instagram",
|
||||
icon: "@",
|
||||
iconBg: "bg-utility-pink-50",
|
||||
iconText: "text-utility-pink-700",
|
||||
countColor: "text-utility-pink-700",
|
||||
delta: "+1",
|
||||
},
|
||||
{
|
||||
source: 'GOOGLE_MY_BUSINESS',
|
||||
label: 'Google My Business',
|
||||
icon: 'G',
|
||||
iconBg: 'bg-utility-blue-light-50',
|
||||
iconText: 'text-utility-blue-light-700',
|
||||
countColor: 'text-utility-blue-light-700',
|
||||
delta: '+3',
|
||||
source: "GOOGLE_MY_BUSINESS",
|
||||
label: "Google My Business",
|
||||
icon: "G",
|
||||
iconBg: "bg-utility-blue-light-50",
|
||||
iconText: "text-utility-blue-light-700",
|
||||
countColor: "text-utility-blue-light-700",
|
||||
delta: "+3",
|
||||
},
|
||||
{
|
||||
source: 'REFERRAL',
|
||||
label: 'Referrals',
|
||||
icon: 'R',
|
||||
iconBg: 'bg-utility-purple-50',
|
||||
iconText: 'text-utility-purple-700',
|
||||
countColor: 'text-utility-purple-700',
|
||||
delta: '+2',
|
||||
source: "REFERRAL",
|
||||
label: "Referrals",
|
||||
icon: "R",
|
||||
iconBg: "bg-utility-purple-50",
|
||||
iconText: "text-utility-purple-700",
|
||||
countColor: "text-utility-purple-700",
|
||||
delta: "+2",
|
||||
},
|
||||
{
|
||||
source: 'WALK_IN',
|
||||
label: 'Walk-ins',
|
||||
icon: 'W',
|
||||
iconBg: 'bg-utility-orange-50',
|
||||
iconText: 'text-utility-orange-700',
|
||||
countColor: 'text-utility-orange-700',
|
||||
delta: '0',
|
||||
source: "WALK_IN",
|
||||
label: "Walk-ins",
|
||||
icon: "W",
|
||||
iconBg: "bg-utility-orange-50",
|
||||
iconText: "text-utility-orange-700",
|
||||
countColor: "text-utility-orange-700",
|
||||
delta: "0",
|
||||
},
|
||||
];
|
||||
|
||||
export const SourceGrid = ({ leads, onSourceFilter, activeSource }: SourceGridProps) => {
|
||||
const countBySource = (source: LeadSource): number =>
|
||||
leads.filter((l) => l.leadSource === source).length;
|
||||
const countBySource = (source: LeadSource): number => leads.filter((l) => l.leadSource === source).length;
|
||||
|
||||
const handleClick = (source: LeadSource) => {
|
||||
if (activeSource === source) {
|
||||
@@ -98,37 +97,25 @@ export const SourceGrid = ({ leads, onSourceFilter, activeSource }: SourceGridPr
|
||||
type="button"
|
||||
onClick={() => handleClick(config.source)}
|
||||
className={cx(
|
||||
'cursor-pointer rounded-xl border border-secondary bg-primary p-4 text-left transition hover:shadow-md',
|
||||
isActive && 'ring-2 ring-brand',
|
||||
"cursor-pointer rounded-xl border border-secondary bg-primary p-4 text-left transition hover:shadow-md",
|
||||
isActive && "ring-2 ring-brand",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
className={cx(
|
||||
'flex size-6 items-center justify-center rounded text-xs font-bold',
|
||||
config.iconBg,
|
||||
config.iconText,
|
||||
)}
|
||||
>
|
||||
<span className={cx("flex size-6 items-center justify-center rounded text-xs font-bold", config.iconBg, config.iconText)}>
|
||||
{config.icon}
|
||||
</span>
|
||||
<span className="text-xs text-quaternary">{config.label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={cx('text-xl font-bold', config.countColor)}>
|
||||
{count}
|
||||
</span>
|
||||
<span className={cx("text-xl font-bold", config.countColor)}>{count}</span>
|
||||
<span
|
||||
className={cx(
|
||||
'text-xs',
|
||||
config.delta.startsWith('+')
|
||||
? 'text-success-primary'
|
||||
: config.delta === '0'
|
||||
? 'text-quaternary'
|
||||
: 'text-error-primary',
|
||||
"text-xs",
|
||||
config.delta.startsWith("+") ? "text-success-primary" : config.delta === "0" ? "text-quaternary" : "text-error-primary",
|
||||
)}
|
||||
>
|
||||
{config.delta === '0' ? 'same' : config.delta}
|
||||
{config.delta === "0" ? "same" : config.delta}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { faBookBookmark, faCirclePlay, faFileCode, faLifeRing, faStars } from "@fortawesome/pro-duotone-svg-icons";
|
||||
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";
|
||||
|
||||
const BookClosed: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBookBookmark} className={className} />;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
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 } from "@/components/base/buttons/button";
|
||||
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">
|
||||
<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>
|
||||
|
||||
<AriaPopover
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user