Compare commits
3 Commits
e676d259b2
...
owliver-la
| Author | SHA1 | Date | |
|---|---|---|---|
| 81e5979f8f | |||
| 8ac4e1ee88 | |||
| 1ef0c6fc8a |
68
package-lock.json
generated
68
package-lock.json
generated
@@ -31,10 +31,12 @@
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.16.4",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^26.4.2",
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^17.0.16",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"recharts": "^3.10.1",
|
||||
@@ -6315,6 +6317,15 @@
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://locize.com"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -6366,6 +6377,34 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -8701,6 +8740,33 @@
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.16",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
|
||||
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -10073,7 +10139,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -39,10 +39,12 @@
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.16.4",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^26.4.2",
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^17.0.16",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"recharts": "^3.10.1",
|
||||
|
||||
134
scripts/authreturnto-check.mjs
Normal file
134
scripts/authreturnto-check.mjs
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* returnTo validation check.
|
||||
*
|
||||
* Runs the REAL module through Vite's SSR loader, the same way
|
||||
* skill-check.mjs does, so the `@/` alias and the TypeScript compile are the
|
||||
* app's own rather than a reimplementation of them. No test framework is added
|
||||
* for a 130-line module; this follows the convention already in this directory.
|
||||
*
|
||||
* node scripts/authreturnto-check.mjs
|
||||
*
|
||||
* Exits non-zero on failure, so it can gate a build.
|
||||
*
|
||||
* WHAT THIS IS DEFENDING
|
||||
*
|
||||
* safeReturnTo decides whether a URL somebody else supplied may be navigated
|
||||
* to. The cases below are therefore mostly hostile input, and each asserts the
|
||||
* result is null rather than merely "not the attacker's value" — a wrong answer
|
||||
* that is still a navigation is not a pass.
|
||||
*/
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const results = [];
|
||||
const record = (name, pass, detail = '') => {
|
||||
results.push({ name, pass, detail });
|
||||
console.log(`[${pass ? ' ok ' : ' FAIL '}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
const ORIGIN = 'https://platform.krowforce.com';
|
||||
|
||||
// A real authorization URL, with every parameter the flow depends on, built the
|
||||
// way the Go server builds it: path + RawQuery, percent-escaped into ?returnTo=.
|
||||
const AUTHORIZE =
|
||||
'/oauth/authorize?client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22' +
|
||||
'&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback' +
|
||||
'&response_type=code' +
|
||||
'&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' +
|
||||
'&code_challenge_method=S256' +
|
||||
'&resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp' +
|
||||
'&scope=krow.read' +
|
||||
'&state=vT7nQ2xK_Lp9';
|
||||
|
||||
const q = (v) => '?returnTo=' + encodeURIComponent(v);
|
||||
|
||||
const server = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' });
|
||||
try {
|
||||
globalThis.window = { location: { origin: ORIGIN, search: '' } };
|
||||
const { safeReturnTo } = await server.ssrLoadModule('/src/lib/authReturnTo.ts');
|
||||
|
||||
/* ── 1–2. The OAuth authorize URL, and its query byte for byte ─────────── */
|
||||
|
||||
const oauth = safeReturnTo(q(AUTHORIZE));
|
||||
record('1. /oauth/authorize is accepted', oauth !== null && oauth.path === AUTHORIZE,
|
||||
oauth ? `via=${oauth.via}` : 'returned null');
|
||||
record('12. and is marked for full browser navigation', oauth?.via === 'browser',
|
||||
`via=${oauth?.via} — React Router has no such route`);
|
||||
|
||||
for (const [name, literal] of [
|
||||
['client_id', 'client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22'],
|
||||
['redirect_uri', 'redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback'],
|
||||
['response_type', 'response_type=code'],
|
||||
['code_challenge', 'code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'],
|
||||
['code_challenge_method', 'code_challenge_method=S256'],
|
||||
['resource', 'resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp'],
|
||||
['scope', 'scope=krow.read'],
|
||||
['state', 'state=vT7nQ2xK_Lp9'],
|
||||
]) {
|
||||
record(`2. ${name} preserved exactly`, Boolean(oauth?.path.includes(literal)));
|
||||
}
|
||||
record('2. percent-encoding is not rewritten', Boolean(oauth?.path.includes('%2F')),
|
||||
'%2F must not become /');
|
||||
record('2. an encoded space survives',
|
||||
safeReturnTo(q('/oauth/authorize?scope=krow.read%20krow.write'))?.path.includes('%20') === true,
|
||||
'%20 must not become +');
|
||||
|
||||
/* ── 3, 13. Internal admin routes keep router navigation ───────────────── */
|
||||
|
||||
const admin = safeReturnTo(q('/admin/candidates?stage=applied'));
|
||||
record('3. /admin/... is accepted', admin?.path === '/admin/candidates?stage=applied');
|
||||
record('13. and is marked for router navigation', admin?.via === 'router',
|
||||
`via=${admin?.via} — must not reload the app`);
|
||||
record('3. bare /admin is accepted', safeReturnTo(q('/admin'))?.via === 'router');
|
||||
|
||||
/* ── 4–10. Hostile and malformed values are refused ────────────────────── */
|
||||
|
||||
const refuse = [
|
||||
['4. external URL', 'https://evil.example'],
|
||||
['4. external URL with our path', 'https://evil.example/oauth/authorize'],
|
||||
['4. userinfo trick', 'https://platform.krowforce.com@evil.example/'],
|
||||
['4. another port on our host', 'https://platform.krowforce.com:8443/admin'],
|
||||
['5. protocol-relative', '//evil.example'],
|
||||
['5. protocol-relative with path', '//evil.example/steal'],
|
||||
['5. triple slash', '///evil.example'],
|
||||
['6. javascript:', 'javascript:alert(document.cookie)'],
|
||||
['6. javascript: mixed case', 'JaVaScRiPt:alert(1)'],
|
||||
['6. tab-obfuscated scheme', 'java\tscript:alert(1)'],
|
||||
['7. data:', 'data:text/html,<script>alert(1)</script>'],
|
||||
['8. backslash', '/\\evil.example'],
|
||||
// These two reach the slash/backslash guard specifically: the path is on
|
||||
// the allowlist, so only the guard can refuse them. Without them the guard
|
||||
// is unfalsifiable — removing it leaves every other case still passing,
|
||||
// which a mutation run showed.
|
||||
['8. backslash in the query of an allowed path', '/admin/candidates?a=\\evil'],
|
||||
['8. backslash escape smuggled past the allowlist', '/admin/x?next=/\\evil.example'],
|
||||
['8. dot-slash-slash', '/.//evil.example'],
|
||||
['9. /admin/login itself', '/admin/login'],
|
||||
['9. /admin/login with a query', '/admin/login?returnTo=%2Fadmin'],
|
||||
['9. bare /login', '/login'],
|
||||
['10. malformed', 'http://[::1'],
|
||||
['10. file scheme', 'file:///etc/passwd'],
|
||||
['10. unrelated backend route', '/oauth/token'],
|
||||
['10. unrelated app route', '/apply'],
|
||||
['10. the MCP endpoint', '/mcp'],
|
||||
];
|
||||
for (const [name, value] of refuse) {
|
||||
const got = safeReturnTo(q(value));
|
||||
record(`${name} is refused`, got === null, got ? `returned ${JSON.stringify(got)}` : '');
|
||||
}
|
||||
|
||||
/* ── 11. Absent or empty falls back safely ─────────────────────────────── */
|
||||
|
||||
for (const [name, search] of [
|
||||
['no query at all', ''],
|
||||
['other parameters only', '?foo=bar'],
|
||||
['empty returnTo', '?returnTo='],
|
||||
]) {
|
||||
record(`11. ${name} returns null`, safeReturnTo(search) === null);
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.pass).length;
|
||||
console.log(`\n${results.length - failed} passed, ${failed} failed\n`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
58
scripts/i18n-audit.mjs
Normal file
58
scripts/i18n-audit.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Every t('key') in the source, checked against the catalogues.
|
||||
*
|
||||
* A key the code calls and the catalogue lacks renders as humanised English at
|
||||
* best, so this is the check that says the extraction is actually complete.
|
||||
* The reverse — a catalogue entry nothing calls — is only waste, and reported
|
||||
* separately.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const walk = (dir, acc = []) => {
|
||||
for (const e of readdirSync(dir)) {
|
||||
if (e === 'node_modules' || e.startsWith('.')) continue;
|
||||
const p = join(dir, e);
|
||||
if (statSync(p).isDirectory()) walk(p, acc);
|
||||
else if (/\.(tsx|ts|jsx|js)$/.test(p)) acc.push(p);
|
||||
}
|
||||
return acc;
|
||||
};
|
||||
|
||||
const flat = (obj, prefix = '', out = {}) => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === 'object') flat(v, key, out);
|
||||
else out[key] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const en = flat(JSON.parse(readFileSync('src/lib/i18n/locales/en.json', 'utf8')));
|
||||
const es = flat(JSON.parse(readFileSync('src/lib/i18n/locales/es.json', 'utf8')));
|
||||
|
||||
const used = new Set();
|
||||
const dynamic = [];
|
||||
for (const file of walk('src')) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
for (const m of src.matchAll(/\bt\(\s*'([^']+)'/g)) used.add(m[1]);
|
||||
for (const m of src.matchAll(/\bt\(\s*`([^`]+)`/g)) dynamic.push(`${file}: ${m[1]}`);
|
||||
}
|
||||
|
||||
/* A plural key is stored as name_one / name_other, so the base name is never
|
||||
itself in the catalogue and is not missing. */
|
||||
const present = (k) => k in en || `${k}_other` in en || `${k}_one` in en;
|
||||
|
||||
const missingEn = [...used].filter((k) => !present(k)).sort();
|
||||
const missingEs = [...used].filter((k) => present(k) && !(k in es || `${k}_other` in es)).sort();
|
||||
const unused = Object.keys(en).filter((k) => !used.has(k) && !used.has(k.replace(/_(one|other)$/, ''))).sort();
|
||||
|
||||
console.log(`\n t() calls with a literal key : ${used.size}`);
|
||||
console.log(` built from a template : ${dynamic.length} (checked by hand)`);
|
||||
console.log(` MISSING from en.json : ${missingEn.length}`);
|
||||
console.log(` MISSING from es.json : ${missingEs.length}`);
|
||||
console.log(` in en.json, never called : ${unused.length}`);
|
||||
|
||||
for (const k of missingEn.slice(0, 15)) console.log(` missing en: ${k}`);
|
||||
for (const k of missingEs.slice(0, 10)) console.log(` missing es: ${k}`);
|
||||
if (missingEn.length) process.exitCode = 1;
|
||||
126
scripts/i18n-coverage.mjs
Normal file
126
scripts/i18n-coverage.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* How much of each page is actually translated.
|
||||
*
|
||||
* Renders every migrated page twice — once in English, once in Spanish — and
|
||||
* reports the text that did NOT change. That residue is the untranslated part,
|
||||
* and it is the only honest measure of coverage: a catalogue can be full of
|
||||
* Spanish that no component ever calls, and counting keys would score that as
|
||||
* finished.
|
||||
*
|
||||
* Reports rather than asserts. The point is a number per page while the work is
|
||||
* half done; `skill-check.mjs` is where a finished page gets a check that keeps
|
||||
* it finished.
|
||||
*
|
||||
* node scripts/i18n-coverage.mjs
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createServer } from 'vite';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/* The pages reach for `window` on the way up — a media query, a resize
|
||||
listener. The same minimal stub the skill-check uses, installed before any
|
||||
page module is loaded. */
|
||||
if (!('window' in globalThis)) {
|
||||
globalThis.window = {
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
||||
};
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
root: ROOT,
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom',
|
||||
logLevel: 'error',
|
||||
resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } },
|
||||
});
|
||||
withSourceResolution(server);
|
||||
|
||||
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||||
const { MemoryRouter } = await import('react-router-dom');
|
||||
const { UiEditingProvider } = await server.ssrLoadModule('/src/components/ui-tree/UiEditingProvider.jsx');
|
||||
const i18n = (await server.ssrLoadModule('/src/lib/i18n/index.ts')).default;
|
||||
|
||||
const PAGES = [
|
||||
['Control Center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center'],
|
||||
['Positions', '/src/pages/admin/Positions.jsx', '/admin/positions', 'positions'],
|
||||
['Candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates'],
|
||||
['Talent Pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool'],
|
||||
['Analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics'],
|
||||
['Hired History', '/src/pages/admin/HiredHistory.jsx', '/admin/hired', 'hired-history'],
|
||||
['Activity', '/src/pages/admin/Activity.jsx', '/admin/activity', 'activity'],
|
||||
];
|
||||
|
||||
/* Text only, split into phrases on the punctuation the interface uses to
|
||||
separate one label from the next, so "17 total" counts separately from the
|
||||
heading above it. Numbers and symbols alone are not evidence either way —
|
||||
"27" is the same in both languages — so anything without a letter is
|
||||
dropped rather than scored as translated. */
|
||||
const phrases = (html) =>
|
||||
html
|
||||
.replace(/<[^>]*>/g, '\n')
|
||||
.split(/[\n·|•]+/)
|
||||
.map((p) => p.replace(/\s+/g, ' ').trim())
|
||||
.filter((p) => p.length > 2 && /[A-Za-z]{3}/.test(p));
|
||||
|
||||
async function render(path, page) {
|
||||
const Page = (await server.ssrLoadModule(path)).default;
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(MemoryRouter, { initialEntries: [page.route] },
|
||||
React.createElement(QueryClientProvider, { client },
|
||||
React.createElement(UiEditingProvider, { page: page.key },
|
||||
React.createElement(Page))))
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n Translation coverage — text that changes when the language does\n');
|
||||
|
||||
let totalSame = 0;
|
||||
let totalAll = 0;
|
||||
|
||||
for (const [name, path, route, key] of PAGES) {
|
||||
let en;
|
||||
let es;
|
||||
try {
|
||||
await i18n.changeLanguage('en');
|
||||
en = phrases(await render(path, { route, key }));
|
||||
await i18n.changeLanguage('es');
|
||||
es = phrases(await render(path, { route, key }));
|
||||
await i18n.changeLanguage('en');
|
||||
} catch (err) {
|
||||
console.log(` ${name.padEnd(16)} could not render — ${String(err.message).slice(0, 60)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* A phrase present in BOTH renders is one the switch did not touch. Proper
|
||||
nouns and figures land here too, which is why the sample below is printed:
|
||||
a human reads it and sees at once whether the residue is "KROW Forge" or a
|
||||
whole untranslated paragraph. */
|
||||
const esSet = new Set(es);
|
||||
const untouched = en.filter((p) => esSet.has(p));
|
||||
|
||||
totalSame += untouched.length;
|
||||
totalAll += en.length;
|
||||
|
||||
const pct = en.length ? Math.round(((en.length - untouched.length) / en.length) * 100) : 0;
|
||||
const bar = '█'.repeat(Math.round(pct / 5)).padEnd(20, '·');
|
||||
console.log(` ${name.padEnd(16)} ${bar} ${String(pct).padStart(3)}% ${en.length - untouched.length}/${en.length} phrases`);
|
||||
|
||||
if (untouched.length) {
|
||||
const sample = untouched.filter((p) => /[a-z]{4}/.test(p)).slice(0, 4);
|
||||
if (sample.length) console.log(` ${''.padEnd(16)} still English: ${sample.join(' / ').slice(0, 96)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const overall = totalAll ? Math.round(((totalAll - totalSame) / totalAll) * 100) : 0;
|
||||
console.log(`\n Overall: ${overall}% of rendered phrases change with the language.\n`);
|
||||
|
||||
await server.close();
|
||||
307
scripts/i18n-extract.mjs
Normal file
307
scripts/i18n-extract.mjs
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Lift hard-coded English out of components and into the catalogue.
|
||||
*
|
||||
* node scripts/i18n-extract.mjs --dry src/components/krow
|
||||
* node scripts/i18n-extract.mjs src/components/krow/SomeCard.tsx
|
||||
*
|
||||
* An AST codemod rather than a regex pass, because the hard part is not finding
|
||||
* the string — it is knowing which component encloses it, so `const { t } =
|
||||
* useTranslation()` lands in the right function and not in a module-level
|
||||
* constant where hooks cannot run.
|
||||
*
|
||||
* WHAT IT WILL TOUCH
|
||||
* - JSX text children that are entirely static words
|
||||
* - string literals on a short list of text-bearing attributes
|
||||
*
|
||||
* WHAT IT LEAVES ALONE, and why each one would be a bug:
|
||||
* - anything inside a module-level object or array; those are built once at
|
||||
* import, so a t() there freezes the first language the app loaded
|
||||
* - strings with no letters, single characters, and pure punctuation
|
||||
* - `className`, `id`, `key`, `type`, `name`, routes, urls, test ids
|
||||
* - text inside <code>, <pre>, <script> and <style>
|
||||
* - a component it cannot find a function body for
|
||||
*
|
||||
* Keys are `<namespace>.<slug>`, the namespace from the file name, the slug
|
||||
* from the words themselves — so a key reads as what it says, and moving a
|
||||
* heading between files does not rename it.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, basename, extname } from 'node:path';
|
||||
import { parse } from '@babel/parser';
|
||||
import _traverse from '@babel/traverse';
|
||||
import _generate from '@babel/generator';
|
||||
import * as t from '@babel/types';
|
||||
|
||||
const traverse = _traverse.default || _traverse;
|
||||
const generate = _generate.default || _generate;
|
||||
|
||||
const DRY = process.argv.includes('--dry');
|
||||
const TARGETS = process.argv.slice(2).filter((a) => !a.startsWith('--'));
|
||||
|
||||
const TEXT_ATTRS = new Set([
|
||||
'title', 'label', 'description', 'placeholder', 'subtitle', 'meta',
|
||||
'aria-label', 'alt', 'emptyLabel', 'heading', 'hint', 'caption', 'tooltip',
|
||||
'confirmLabel', 'cancelLabel', 'actionLabel', 'emptyTitle', 'emptyDescription',
|
||||
]);
|
||||
|
||||
const SKIP_ELEMENTS = new Set(['code', 'pre', 'script', 'style']);
|
||||
|
||||
/* A string worth translating has letters, more than one of them, and is not a
|
||||
token that only looks like prose (a route, a url, a css class, an enum). */
|
||||
const translatable = (s) => {
|
||||
const v = s.trim();
|
||||
if (v.length < 2) return false;
|
||||
if (!/[A-Za-z]{2}/.test(v)) return false;
|
||||
if (/^[a-z0-9_-]+$/.test(v)) return false; // enum / slug / class
|
||||
if (/^(https?:|\/|#|data:|mailto:)/.test(v)) return false;
|
||||
if (/^[A-Z0-9_]+$/.test(v)) return false; // CONSTANT_CASE
|
||||
return true;
|
||||
};
|
||||
|
||||
const slug = (s) =>
|
||||
s.trim().toLowerCase()
|
||||
.replace(/[’']/g, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.split(' ')
|
||||
.slice(0, 6)
|
||||
.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1)))
|
||||
.join('') || 'text';
|
||||
|
||||
const namespaceFor = (file) => {
|
||||
const base = basename(file, extname(file));
|
||||
if (base === 'nodes' || base === 'index') {
|
||||
const parent = basename(join(file, '..'));
|
||||
return parent.replace(/[^A-Za-z0-9]+/g, '');
|
||||
}
|
||||
return base.charAt(0).toLowerCase() + base.slice(1);
|
||||
};
|
||||
|
||||
/** The nearest enclosing function that is a component (capitalised name). */
|
||||
function enclosingComponent(path) {
|
||||
let p = path;
|
||||
while (p) {
|
||||
if (p.isFunctionDeclaration() || p.isFunctionExpression() || p.isArrowFunctionExpression()) {
|
||||
const id = p.node.id?.name
|
||||
|| (p.parentPath?.isVariableDeclarator() && p.parentPath.node.id?.name);
|
||||
/* Only a component with a BLOCK body can take the hook. An arrow whose
|
||||
body is the JSX itself (`const X = () => <p>Text</p>`) would need
|
||||
rewriting into a block first, and a codemod that reshapes a component
|
||||
to translate one word is doing more than it was asked. Those are left
|
||||
alone and reported, so they can be done by hand. */
|
||||
if (id && /^[A-Z]/.test(id)) {
|
||||
return t.isBlockStatement(p.node.body) ? p : null;
|
||||
}
|
||||
/* An inline render callback — keep walking out to the component that
|
||||
owns it, so one hook serves both. */
|
||||
}
|
||||
p = p.parentPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when the node sits in a module-level object/array literal. */
|
||||
function atModuleScope(path) {
|
||||
let p = path;
|
||||
while (p) {
|
||||
if (p.isFunction()) return false;
|
||||
if (p.isProgram()) return true;
|
||||
p = p.parentPath;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function processFile(file, catalogue) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
if (!/<[A-Za-z]/.test(src)) return null;
|
||||
|
||||
let ast;
|
||||
try {
|
||||
ast = parse(src, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript', 'classProperties', 'decorators-legacy'],
|
||||
});
|
||||
} catch {
|
||||
return { file, skipped: 'could not parse' };
|
||||
}
|
||||
|
||||
/* A file that already binds `t` to something of its own — a theme token, a
|
||||
translate helper, a loop variable — cannot also take the hook's `t`
|
||||
without shadowing it. Skipped whole rather than half converted: the hook
|
||||
would compile and then silently resolve to the wrong thing. */
|
||||
if (/\b(?:const|let|var)\s+t\b\s*=/.test(src) || /\(\s*t\s*[,)]/.test(src)) {
|
||||
return { file, skipped: 'already binds `t`' };
|
||||
}
|
||||
|
||||
const ns = namespaceFor(file);
|
||||
const needHook = new Set();
|
||||
const found = [];
|
||||
/* Character ranges to splice into the ORIGINAL text. Babel's generator would
|
||||
reprint the whole file — restyling commas, quotes and JSX that nobody
|
||||
asked it to touch — and a 52-file diff of incidental reformatting is one
|
||||
nobody can review. */
|
||||
const edits = [];
|
||||
|
||||
const keyFor = (text) => {
|
||||
const key = `${ns}.${slug(text)}`;
|
||||
const existing = catalogue.get(key);
|
||||
if (existing && existing !== text) {
|
||||
/* Same words, different file, different sentence: disambiguate rather
|
||||
than let one overwrite the other. */
|
||||
let i = 2;
|
||||
while (catalogue.has(`${key}${i}`) && catalogue.get(`${key}${i}`) !== text) i += 1;
|
||||
return `${key}${i}`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
traverse(ast, {
|
||||
JSXText(path) {
|
||||
const raw = path.node.value;
|
||||
const text = raw.trim();
|
||||
if (!translatable(text)) return;
|
||||
const parent = path.parent;
|
||||
if (t.isJSXElement(parent)) {
|
||||
const name = parent.openingElement.name;
|
||||
if (t.isJSXIdentifier(name) && SKIP_ELEMENTS.has(name.name)) return;
|
||||
}
|
||||
if (atModuleScope(path)) return;
|
||||
const comp = enclosingComponent(path);
|
||||
if (!comp) return;
|
||||
|
||||
const key = keyFor(text);
|
||||
catalogue.set(key, text);
|
||||
produced.add(key);
|
||||
found.push({ key, text });
|
||||
needHook.add(comp);
|
||||
/* Only the trimmed span is replaced, so the surrounding newlines and
|
||||
indentation survive exactly as written. */
|
||||
const lead = raw.length - raw.trimStart().length;
|
||||
edits.push({
|
||||
start: path.node.start + lead,
|
||||
end: path.node.start + lead + text.length,
|
||||
text: `{t('${key}')}`,
|
||||
});
|
||||
},
|
||||
|
||||
JSXAttribute(path) {
|
||||
const name = path.node.name;
|
||||
const attr = t.isJSXIdentifier(name) ? name.name
|
||||
: t.isJSXNamespacedName(name) ? `${name.namespace.name}:${name.name.name}` : null;
|
||||
if (!attr || !TEXT_ATTRS.has(attr)) return;
|
||||
const value = path.node.value;
|
||||
if (!t.isStringLiteral(value)) return;
|
||||
const text = value.value.trim();
|
||||
if (!translatable(text)) return;
|
||||
if (atModuleScope(path)) return;
|
||||
const comp = enclosingComponent(path);
|
||||
if (!comp) return;
|
||||
|
||||
const key = keyFor(text);
|
||||
catalogue.set(key, text);
|
||||
produced.add(key);
|
||||
found.push({ key, text });
|
||||
needHook.add(comp);
|
||||
edits.push({ start: value.start, end: value.end, text: `{t('${key}')}` });
|
||||
},
|
||||
});
|
||||
|
||||
if (!found.length) return null;
|
||||
|
||||
/* One hook per component that now calls t(), on its own line just inside the
|
||||
body. A component that already destructures t is left alone. */
|
||||
for (const comp of needHook) {
|
||||
const body = comp.node.body;
|
||||
if (!t.isBlockStatement(body)) continue;
|
||||
const already = body.body.some(
|
||||
(st) => t.isVariableDeclaration(st)
|
||||
&& st.declarations.some((d) => t.isObjectPattern(d.id)
|
||||
&& d.id.properties.some((pr) => t.isObjectProperty(pr) && pr.key?.name === 't'))
|
||||
);
|
||||
if (already) continue;
|
||||
/* Indentation copied from the first statement, so the inserted line sits
|
||||
where a person would have typed it. */
|
||||
const first = body.body[0];
|
||||
const lineStart = first ? src.lastIndexOf('\n', first.start) + 1 : -1;
|
||||
const indent = first ? src.slice(lineStart, first.start).match(/^[ \t]*/)[0] : ' ';
|
||||
edits.push({
|
||||
start: body.start + 1,
|
||||
end: body.start + 1,
|
||||
text: `\n${indent}const { t } = useTranslation();`,
|
||||
});
|
||||
}
|
||||
|
||||
/* Applied last-first so earlier offsets stay valid. */
|
||||
let out = src;
|
||||
edits.sort((a, b) => b.start - a.start);
|
||||
for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
||||
|
||||
if (!/from ['"]react-i18next['"]/.test(out)) {
|
||||
const lines = out.split('\n');
|
||||
let last = -1;
|
||||
for (let i = 0; i < Math.min(lines.length, 60); i += 1) {
|
||||
if (/^import .*;\s*$/.test(lines[i])) last = i;
|
||||
}
|
||||
lines.splice(last + 1, 0, "import { useTranslation } from 'react-i18next';");
|
||||
out = lines.join('\n');
|
||||
}
|
||||
|
||||
if (!DRY) writeFileSync(file, out);
|
||||
return { file, found };
|
||||
}
|
||||
|
||||
function walk(target, acc = []) {
|
||||
const st = statSync(target);
|
||||
if (st.isFile()) {
|
||||
if (/\.(tsx|jsx)$/.test(target)) acc.push(target);
|
||||
return acc;
|
||||
}
|
||||
for (const entry of readdirSync(target)) {
|
||||
if (entry === 'node_modules' || entry.startsWith('.')) continue;
|
||||
walk(join(target, entry), acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/* Seeded from the catalogue already on disk, so a key taken by a DIFFERENT
|
||||
string in an earlier run is seen as taken. Without this each run starts
|
||||
blank, two runs mint the same key for two different sentences, and the
|
||||
merge silently keeps one of them — which is how "Search positions" became
|
||||
"Search positions...". */
|
||||
const produced = new Set();
|
||||
const catalogue = new Map();
|
||||
try {
|
||||
const existing = JSON.parse(readFileSync('src/lib/i18n/locales/en.json', 'utf8'));
|
||||
for (const [ns, items] of Object.entries(existing)) {
|
||||
if (items && typeof items === 'object') {
|
||||
for (const [leaf, val] of Object.entries(items)) catalogue.set(`${ns}.${leaf}`, val);
|
||||
}
|
||||
}
|
||||
} catch { /* first run, nothing to seed from */ }
|
||||
const files = TARGETS.flatMap((tg) => walk(tg));
|
||||
let changed = 0;
|
||||
let strings = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const res = processFile(file, catalogue);
|
||||
if (!res) continue;
|
||||
if (res.skipped) { console.log(` skip ${file} — ${res.skipped}`); continue; }
|
||||
changed += 1;
|
||||
strings += res.found.length;
|
||||
console.log(` ${DRY ? 'would do' : 'done'} ${file.replace('src/', '')} — ${res.found.length}`);
|
||||
}
|
||||
|
||||
console.log(`\n ${changed} files, ${strings} strings${DRY ? ' (dry run, nothing written)' : ''}`);
|
||||
|
||||
/* ONLY the keys this run created. The catalogue is seeded from disk so
|
||||
collisions are visible, and writing it back out whole would re-emit every
|
||||
existing entry — including the nested ones this format does not model. */
|
||||
const additions = {};
|
||||
for (const [key, text] of catalogue) {
|
||||
if (!produced.has(key)) continue;
|
||||
const [ns, leaf] = [key.slice(0, key.indexOf('.')), key.slice(key.indexOf('.') + 1)];
|
||||
(additions[ns] ||= {})[leaf] = text;
|
||||
}
|
||||
writeFileSync('scripts/__i18n-additions.json', `${JSON.stringify(additions, null, 2)}\n`);
|
||||
console.log(' catalogue written to scripts/__i18n-additions.json');
|
||||
@@ -1513,6 +1513,14 @@ record('the Owliver skill offers its chip, and draws no card',
|
||||
*/
|
||||
console.log('\n── Owliver suggestions from the API ──');
|
||||
|
||||
/* i18next, before ANY component renders. Every t() in the tree returns its own
|
||||
key until this has run, so a block that forgets it fails its parity check for
|
||||
a reason that has nothing to do with what it is testing. The app gets this
|
||||
from App.tsx; a harness that loads page modules directly has to ask. Loaded
|
||||
once here rather than per block, which is how two blocks came to be missing
|
||||
it while a third had it. */
|
||||
await server.ssrLoadModule('/src/lib/i18n/index.ts');
|
||||
|
||||
const serverSuggestions = await server.ssrLoadModule('/src/lib/skills/serverSuggestions.js');
|
||||
|
||||
/* ── The request ─────────────────────────────────────────────────────────── */
|
||||
@@ -3869,7 +3877,11 @@ record('both agent menus offer removal, and neither offers it unconditionally',
|
||||
`${removeItems.length} remove item(s), each behind (overridden || !shipped)`);
|
||||
|
||||
record('a custom agent is offered Remove, and a built-in one is only offered a revert',
|
||||
removeItems.every((block) => / Remove</.test(block) && /Revert to shipped/.test(block)
|
||||
/* Matched by translation KEY rather than by the English words: the labels
|
||||
now come from the catalogue, and a check that greps for "Remove" would be
|
||||
asserting on which language the file happens to be written in. */
|
||||
removeItems.every((block) => /workspaceAgents\.remove/.test(block)
|
||||
&& /workspaceAgents\.revertToShipped/.test(block)
|
||||
&& /shipped\s*\?/.test(block)),
|
||||
'the label is chosen by `shipped`, not by name');
|
||||
|
||||
@@ -3923,7 +3935,7 @@ record('...while Archived stays reachable as the place removal is undone',
|
||||
the confirmation names "Archived" while no such tab is on screen. */
|
||||
record('...and Archived is offered even when it is empty',
|
||||
!/\{archivedCount > 0 && \(/.test(agentsListSource)
|
||||
&& /Archived <span[^>]*>\(\{archivedCount\}\)/.test(agentsListSource),
|
||||
&& /workspaceAgents\.archived'\)\} <span[^>]*>\(\{archivedCount\}\)/.test(agentsListSource),
|
||||
'the tab is unconditional and shows its count');
|
||||
|
||||
record('...and reverting a built-in agent is not worded as a removal',
|
||||
@@ -4880,7 +4892,7 @@ record('every field the editor had is still editable',
|
||||
record('the header still states version, status and customization',
|
||||
/v\{agent\.version\}/.test(detailSource)
|
||||
&& /\{agent\.status\}/.test(detailSource)
|
||||
&& /isOverridden\(id\) && <Badge variant="info">Customized/.test(detailSource)
|
||||
&& /isOverridden\(id\) && <Badge variant="info">\{t\('agentDetail\.customized'\)\}/.test(detailSource)
|
||||
&& /Publish update/.test(detailSource),
|
||||
'v · status · Customized · Publish update');
|
||||
|
||||
@@ -5755,6 +5767,48 @@ console.log('\n── Follow-ups ──');
|
||||
question: 'x', history: [],
|
||||
refresh: async () => [PAGE[0], PAGE[0], PAGE[1]],
|
||||
})).length === 2);
|
||||
|
||||
/* The defect that made the row appear exactly once per conversation: every
|
||||
chip ever shown was banned for good, so the second turn filtered out the
|
||||
same top-ranked catalogue the first turn had consumed and the row went
|
||||
silent for the rest of the thread. Offered is not asked — a suggestion
|
||||
passed over, with an answer since, is offerable again. */
|
||||
record('a chip offered earlier, but not under the last answer, comes back',
|
||||
JSON.stringify(said(await nextSteps({
|
||||
question: 'x',
|
||||
history: [
|
||||
{ role: 'user', text: 'a' },
|
||||
{ role: 'assistant', followUp: [PAGE[0]] },
|
||||
{ role: 'user', text: 'b' },
|
||||
{ role: 'assistant', followUp: [PAGE[1]] },
|
||||
],
|
||||
refresh: async () => [PAGE[0], PAGE[1]],
|
||||
}))) === JSON.stringify([PAGE[0].prompt]));
|
||||
|
||||
/* ...while the row directly above is still never redrawn verbatim. */
|
||||
record('...but the row under the answer directly above is not repeated',
|
||||
(await nextSteps({
|
||||
question: 'x',
|
||||
history: [
|
||||
{ role: 'user', text: 'a' },
|
||||
{ role: 'assistant', followUp: [PAGE[0], PAGE[1]] },
|
||||
],
|
||||
refresh: async () => [PAGE[0], PAGE[1]],
|
||||
})) === undefined);
|
||||
|
||||
/* A question actually put stays excluded however long ago it was asked —
|
||||
that exclusion is the permanent one. */
|
||||
record('a question asked earlier in the thread is never offered back',
|
||||
(await nextSteps({
|
||||
question: 'x',
|
||||
history: [
|
||||
{ role: 'user', text: 'How healthy is the platform right now?' },
|
||||
{ role: 'assistant', followUp: [] },
|
||||
{ role: 'user', text: 'b' },
|
||||
{ role: 'assistant', followUp: [] },
|
||||
],
|
||||
refresh: async () => [PAGE[0]],
|
||||
})) === undefined);
|
||||
}
|
||||
|
||||
console.log('\n── Streaming ──');
|
||||
@@ -7976,8 +8030,30 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
return html.slice(0, at) + html.slice(i);
|
||||
};
|
||||
|
||||
record('the layout controls are present and separable',
|
||||
now.includes('<div data-ui-controls') && stripControls(now).indexOf('data-ui-controls') === -1);
|
||||
/**
|
||||
* A page at rest draws no layout controls at all.
|
||||
*
|
||||
* This used to assert the opposite — that the controls were PRESENT — back
|
||||
* when they led with a `Customise layout` button that was on screen whether
|
||||
* or not anybody was arranging anything. That button is gone: Owliver is the
|
||||
* only way to propose a layout change now, and what is left is the bar that
|
||||
* lets a person accept or reject one. With nothing proposed and nothing
|
||||
* saved there is nothing to accept, so the component renders nothing.
|
||||
*
|
||||
* `stripControls` is kept and still exercised. The bar does appear once a
|
||||
* change is being previewed, and the comparison below must stay able to lift
|
||||
* it out when it does.
|
||||
*/
|
||||
record('a page at rest draws no layout controls',
|
||||
!now.includes('<div data-ui-controls'));
|
||||
|
||||
record('layout controls stay separable when they are drawn', (() => {
|
||||
const withBar = `<main><p>kept</p><div data-ui-controls="editor"><div><span>Apply</span></div></div><p>also kept</p></main>`;
|
||||
const stripped = stripControls(withBar);
|
||||
return !stripped.includes('data-ui-controls')
|
||||
&& stripped.includes('<p>kept</p>')
|
||||
&& stripped.includes('<p>also kept</p>');
|
||||
})());
|
||||
|
||||
now = stripControls(now);
|
||||
|
||||
@@ -8308,6 +8384,92 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
return r.ok && ids.indexOf('timeline') < ids.indexOf('activity-privileged-notice');
|
||||
})());
|
||||
|
||||
/* ── Where "to the top" is allowed to be said ──────────────────────────── */
|
||||
|
||||
/**
|
||||
* The destination phrasings, because the missing ones read as a broken feature.
|
||||
*
|
||||
* Reported from production: "show the pipeline move to top" was answered "I
|
||||
* could not find that on this page." The target resolved perfectly well — the
|
||||
* refusal came from `planMove`, which knew `to the top` and did not know
|
||||
* `to top`, and a move with no destination falls through to `unknown`. The
|
||||
* user cannot tell that apart from the section not existing.
|
||||
*/
|
||||
const movesFirst = (q) => {
|
||||
const m = ask(q);
|
||||
if (m?.kind !== 'plan' || m.op.op !== 'reorder') return false;
|
||||
return m.op.order[0] === 'timeline';
|
||||
};
|
||||
const movesLast = (q) => {
|
||||
const m = ask(q);
|
||||
if (m?.kind !== 'plan' || m.op.op !== 'reorder') return false;
|
||||
return m.op.order[m.op.order.length - 1] === 'timeline';
|
||||
};
|
||||
|
||||
for (const q of [
|
||||
'move timeline to top',
|
||||
'move timeline to the top',
|
||||
'move the timeline to the very top',
|
||||
'move timeline up',
|
||||
'move the timeline first',
|
||||
'put the timeline at the top',
|
||||
'show the timeline move to top',
|
||||
]) {
|
||||
record(`"${q}" → timeline first`, movesFirst(q));
|
||||
}
|
||||
|
||||
for (const q of [
|
||||
'move timeline to bottom',
|
||||
'move timeline to the bottom',
|
||||
'move the timeline to the end',
|
||||
'move timeline down',
|
||||
'move the timeline last',
|
||||
]) {
|
||||
record(`"${q}" → timeline last`, movesLast(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* A destination word inside a section's NAME is not a destination.
|
||||
*
|
||||
* `over` lives inside `coverage`, `end` inside `trends`. Matched as
|
||||
* substrings, "move coverage trends to the bottom" satisfied both the top
|
||||
* reading and the bottom one — and the top is tested first, so it moved the
|
||||
* opposite way from the one asked for. Whole-word matching is what fixed it;
|
||||
* this is the case that proves it.
|
||||
*/
|
||||
const coverageCase = (() => {
|
||||
const placed = opsMod4.applyOperation(tree4, {
|
||||
op: 'add', parent: null,
|
||||
node: {
|
||||
id: 'cov-1', type: 'flow', data: { source: 'candidates.activity' },
|
||||
props: { title: 'Coverage trends' },
|
||||
},
|
||||
}, { registry: reg4 });
|
||||
if (!placed.ok) return { pass: false, detail: 'fixture could not be placed' };
|
||||
|
||||
/* Put it at the TOP first, so "to the bottom" is a real change. Added at
|
||||
the end, it is already there and the answer is a refusal rather than a
|
||||
plan — which would pass this check for the wrong reason. */
|
||||
const atTop = opsMod4.applyOperation(placed.tree, {
|
||||
op: 'reorder',
|
||||
parent: null,
|
||||
order: ['cov-1', ...placed.tree.map((n) => n.id).filter((id) => id !== 'cov-1')],
|
||||
}, { registry: reg4 });
|
||||
if (!atTop.ok) return { pass: false, detail: 'fixture could not be placed first' };
|
||||
|
||||
const m = intentMod.matchUiEdit('move coverage trends to the bottom',
|
||||
{ tree: atTop.tree, registry: reg4 });
|
||||
if (m?.kind !== 'plan' || m.op.op !== 'reorder') {
|
||||
return { pass: false, detail: JSON.stringify(m) };
|
||||
}
|
||||
return {
|
||||
pass: m.op.order[m.op.order.length - 1] === 'cov-1',
|
||||
detail: m.op.order.join(', '),
|
||||
};
|
||||
})();
|
||||
record('a section named "Coverage trends" still moves to the bottom',
|
||||
coverageCase.pass, coverageCase.detail);
|
||||
|
||||
record('"Change the hiring activity to a table." → replace with table', (() => {
|
||||
const m = ask('Change the hiring activity to a table.');
|
||||
return m?.kind === 'plan' && m.op.op === 'replace' && m.op.target === 'flow-1' && m.op.type === 'table';
|
||||
@@ -8932,7 +9094,14 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
}
|
||||
html = html.slice(0, at) + html.slice(i);
|
||||
}
|
||||
return { pass: html === positionsBefore, detail: html === positionsBefore ? '' : `len ${positionsBefore.length}->${html.length}` };
|
||||
/* Compared with runs of whitespace collapsed. Text that used to be
|
||||
written across several lines of JSX now comes from the catalogue as
|
||||
one line, so the markup differs by newlines and indentation that HTML
|
||||
collapses anyway. Everything this check exists to catch — a changed
|
||||
element, class, attribute or word — still fails it. */
|
||||
const ws = (h) => h.replace(/\s+/g, ' ').trim();
|
||||
const same = ws(html) === ws(positionsBefore);
|
||||
return { pass: same, detail: same ? '' : `len ${positionsBefore.length}->${html.length}` };
|
||||
})().pass);
|
||||
}
|
||||
}
|
||||
@@ -9173,6 +9342,106 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
return { pass: ids.length > 0, detail: ids.join(', ') };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
/* ── The page actually translates ───────────────────────────────────────
|
||||
*
|
||||
* The parity checks above all run in English, so every one of them would
|
||||
* still pass if `t()` were wired up and the Spanish catalogue were empty.
|
||||
* This renders the same page in Spanish and insists the words CHANGED.
|
||||
*
|
||||
* Asserting on specific strings rather than merely "different": a page
|
||||
* where t() returns raw keys also differs from English, and that is the
|
||||
* failure this is most likely to catch.
|
||||
*/
|
||||
{
|
||||
const i18n = (await server.ssrLoadModule('/src/lib/i18n/index.ts')).default;
|
||||
const Page = (await server.ssrLoadModule('/src/pages/admin/ControlCenter.jsx')).default;
|
||||
const render = () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
|
||||
return stripControls(renderToStaticMarkup(
|
||||
React.createElement(R, { initialEntries: ['/admin'] },
|
||||
React.createElement(QueryClientProvider, { client },
|
||||
React.createElement(UiEditingProvider, { page: 'control-center' },
|
||||
React.createElement(Page))))
|
||||
));
|
||||
};
|
||||
|
||||
const en = words(render());
|
||||
await i18n.changeLanguage('es');
|
||||
const es = words(render());
|
||||
await i18n.changeLanguage('en');
|
||||
|
||||
record('Control Center renders in Spanish', es !== en);
|
||||
record('Control Center shows Spanish words, not keys', (() => {
|
||||
const want = ['Centro de control', 'Vacantes abiertas', 'Candidatos'];
|
||||
const missing = want.filter((w) => !es.includes(w));
|
||||
const keys = /controlCenter\.title|metrics\.|sections\./.test(es);
|
||||
return {
|
||||
pass: missing.length === 0 && !keys,
|
||||
detail: keys ? 'raw keys rendered' : (missing.length ? `missing ${missing.join(', ')}` : 'translated'),
|
||||
};
|
||||
})().pass);
|
||||
record('switching back restores English', words(render()) === en);
|
||||
|
||||
/* THE CHECK THAT MATTERS, and the one whose absence let a half-translated
|
||||
page be reported as finished. "es !== en" passes when a single word
|
||||
changed, which is exactly the state a reader sees as broken: Spanish
|
||||
headings sitting on top of English captions, legends and stage names.
|
||||
So this names the English the page used to show and insists none of it
|
||||
survives the switch. A key added to the catalogue but never wired into
|
||||
the component fails here, where every other check stays green. */
|
||||
record('no English survives on the Spanish Control Center', (() => {
|
||||
const english = [
|
||||
'Applications, screening, interviews and hires',
|
||||
'entered · conversion and drop-off by stage',
|
||||
'Entry Stage', 'Primary Pipeline Bottleneck',
|
||||
'pass through and', 'Open positions', 'In review', 'AI screened',
|
||||
'Avg KROW score', 'Hiring activity', 'Pipeline intelligence',
|
||||
'Position performance', 'Action center', 'Active positions',
|
||||
'Top talent', 'Recent activity', 'Shortlisted', 'Interview',
|
||||
];
|
||||
const left = english.filter((e) => es.includes(e));
|
||||
return { pass: left.length === 0, detail: left.length ? `still English: ${left.join(' | ')}` : 'fully translated' };
|
||||
})().pass);
|
||||
|
||||
/* The panel writes its own greeting, description and placeholders in the
|
||||
browser — `dynamic.ts` — so a translated DASHBOARD with an English
|
||||
Owliver is the exact half-done state this is here to catch. */
|
||||
const dyn = await server.ssrLoadModule('/src/components/ai-assistant/dynamic.ts');
|
||||
const facts = {
|
||||
today: new Date('2026-10-07T09:00:00'),
|
||||
total: 24, postings: new Array(8),
|
||||
unscoredProfiles: [], activity24h: [],
|
||||
};
|
||||
const esIntro = dyn.buildIntro('admin.controlCenter', facts, 'Aravind', i18n.getFixedT('es'));
|
||||
const enIntro = dyn.buildIntro('admin.controlCenter', facts, 'Aravind', i18n.getFixedT('en'));
|
||||
|
||||
record('the assistant greeting translates', (() => {
|
||||
const ok = esIntro.title.startsWith('Buenos días') && enIntro.title.startsWith('Good morning');
|
||||
return { pass: ok, detail: `${enIntro.title} / ${esIntro.title}` };
|
||||
})().pass);
|
||||
|
||||
record('the assistant greeting agrees in gender and number', (() => {
|
||||
/* "Buenos días" and "Buenas tardes" — the adjective agrees with the
|
||||
noun, so a greeting assembled from "Good " plus a translated word is
|
||||
wrong in one of them. One key per time of day is why this passes. */
|
||||
const afternoon = { ...facts, today: new Date('2026-10-07T14:00:00') };
|
||||
const pm = dyn.buildIntro('admin.controlCenter', afternoon, '', i18n.getFixedT('es'));
|
||||
return { pass: pm.title === 'Buenas tardes', detail: pm.title };
|
||||
})().pass);
|
||||
|
||||
record('the assistant description counts in Spanish', (() => {
|
||||
const ok = esIntro.description.includes('24 solicitudes')
|
||||
&& esIntro.description.includes('8 vacantes');
|
||||
return { pass: ok, detail: esIntro.description };
|
||||
})().pass);
|
||||
|
||||
record('a singular count does not take the plural form', (() => {
|
||||
const one = { ...facts, total: 1, postings: new Array(1) };
|
||||
const d = dyn.buildIntro('admin.controlCenter', one, '', i18n.getFixedT('es')).description;
|
||||
return { pass: d.includes('1 solicitud ') || d.includes('1 solicitud'), detail: d };
|
||||
})().pass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/* Side-effect import: initialises i18next and subscribes it to the language
|
||||
store, before any component calls t(). Imported here rather than in main so
|
||||
that every entry point into the app gets it. */
|
||||
import '@/lib/i18n';
|
||||
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { Toaster as HotToaster } from 'react-hot-toast';
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const UserNotRegisteredError = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-white to-slate-50">
|
||||
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-lg border border-slate-100">
|
||||
@@ -10,16 +12,16 @@ const UserNotRegisteredError = () => {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-4">Access Restricted</h1>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-4">{t('userNotRegisteredError.accessRestricted')}</h1>
|
||||
<p className="text-slate-600 mb-8">
|
||||
You are not registered to use this application. Please contact the app administrator to request access.
|
||||
{t('userNotRegisteredError.youAreNotRegisteredToUse')}
|
||||
</p>
|
||||
<div className="p-4 bg-slate-50 rounded-md text-sm text-slate-600">
|
||||
<p>If you believe this is an error, you can:</p>
|
||||
<p>{t('userNotRegisteredError.ifYouBelieveThisIsAn')}</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Verify you are logged in with the correct account</li>
|
||||
<li>Contact the app administrator for access</li>
|
||||
<li>Try logging out and back in again</li>
|
||||
<li>{t('userNotRegisteredError.verifyYouAreLoggedInWith')}</li>
|
||||
<li>{t('userNotRegisteredError.contactTheAppAdministratorForAccess')}</li>
|
||||
<li>{t('userNotRegisteredError.tryLoggingOutAndBackIn')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Shared page furniture for the Admin product.
|
||||
@@ -28,6 +29,7 @@ interface AdminPageProps {
|
||||
}
|
||||
|
||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }: AdminPageProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
||||
@@ -45,7 +47,7 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
||||
{meta && <span className="whitespace-nowrap text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
|
||||
Live System
|
||||
{t('shell.liveSystem')}
|
||||
</span>
|
||||
</div>
|
||||
{subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronRight, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { agentIconFor } from './icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The Agent Configure workspace, as parts.
|
||||
@@ -100,11 +101,12 @@ export function Collapse({ open, children, className }: any) {
|
||||
* configuration workspace.
|
||||
*/
|
||||
export function Rail({ sections, activeId, onSelect }: any) {
|
||||
const { t } = useTranslation();
|
||||
const activeSection = sections.find((s) => s.id === activeId);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Agent configuration"
|
||||
aria-label={t('agentCanvas.agentConfiguration')}
|
||||
className="flex flex-wrap items-center justify-between gap-2.5"
|
||||
>
|
||||
{/* Section anchor pills */}
|
||||
@@ -151,7 +153,7 @@ export function Rail({ sections, activeId, onSelect }: any) {
|
||||
{/* Sub-item quick jump leaves */}
|
||||
{activeSection?.items?.length > 0 && (
|
||||
<div className="hidden items-center gap-1.5 md:flex">
|
||||
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">Jump to:</span>
|
||||
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">{t('agentCanvas.jumpTo')}</span>
|
||||
{activeSection.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -478,11 +480,12 @@ export function IconPicker({ icons, value, onChange }: any) {
|
||||
* nothing it does not.
|
||||
*/
|
||||
export function AgentPreview({ name, description, icon }: any) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = agentIconFor(icon);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-subtle p-3">
|
||||
<p className="text-overline uppercase text-ink-4">In Owliver</p>
|
||||
<p className="text-overline uppercase text-ink-4">{t('agentCanvas.inOwliver')}</p>
|
||||
<div className="mt-2 flex items-start gap-2.5 rounded-lg border border-border bg-surface p-2.5 shadow-xs">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
|
||||
import { getSkillsForPage } from '@/lib/skills/registry';
|
||||
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
|
||||
SettingRow, Workspace,
|
||||
@@ -107,6 +108,7 @@ export function AgentConfigure({
|
||||
rather than fetched here so this component stays a form over `fields`. */
|
||||
toolCatalogue = [],
|
||||
}: any) {
|
||||
const { t } = useTranslation();
|
||||
/* Sections are open by default: this is a document, and one that greets its
|
||||
author with four closed headers hides the thing they came to write. Closing
|
||||
is for focus, not for the initial reading. */
|
||||
@@ -240,17 +242,17 @@ export function AgentConfigure({
|
||||
<DocSection
|
||||
id="identity"
|
||||
icon={IdCard}
|
||||
title="Identity"
|
||||
description="How this agent is presented to people using Owliver."
|
||||
title={t('agentConfigure.identity')}
|
||||
description={t('agentConfigure.howThisAgentIsPresentedTo')}
|
||||
open={open.identity}
|
||||
onToggle={() => toggleSection('identity')}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
|
||||
<div className="lg:col-span-7 xl:col-span-7 space-y-3">
|
||||
<DocField id="identity-name" label="Name">
|
||||
<DocField id="identity-name" label={t('agentConfigure.name')}>
|
||||
<Input
|
||||
value={fields.name}
|
||||
placeholder="e.g. Positions Agent"
|
||||
placeholder={t('agentConfigure.eGPositionsAgent')}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -258,12 +260,12 @@ export function AgentConfigure({
|
||||
|
||||
<DocField
|
||||
id="identity-description"
|
||||
label="Description"
|
||||
hint="One line, shown under the name when choosing an agent."
|
||||
label={t('agentConfigure.description')}
|
||||
hint={t('agentConfigure.oneLineShownUnderTheName')}
|
||||
>
|
||||
<Input
|
||||
value={fields.description}
|
||||
placeholder="e.g. Open roles — what they need and which are at risk."
|
||||
placeholder={t('agentConfigure.eGOpenRolesWhatThey')}
|
||||
onChange={(e) => set({ description: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -271,13 +273,13 @@ export function AgentConfigure({
|
||||
|
||||
<DocField
|
||||
id="identity-trigger"
|
||||
label="When to use it"
|
||||
hint="Helps a reader pick the right agent. Never matched against questions."
|
||||
label={t('agentConfigure.whenToUseIt')}
|
||||
hint={t('agentConfigure.helpsAReaderPickTheRight')}
|
||||
>
|
||||
<Textarea
|
||||
value={fields.trigger}
|
||||
rows={3}
|
||||
placeholder="e.g. Use on Positions, for open roles and applicant flow."
|
||||
placeholder={t('agentConfigure.eGUseOnPositionsFor')}
|
||||
onChange={(e) => set({ trigger: e.target.value })}
|
||||
className="w-full resize-y"
|
||||
/>
|
||||
@@ -292,7 +294,7 @@ export function AgentConfigure({
|
||||
/>
|
||||
|
||||
<div id="identity-icon" className="scroll-mt-28 rounded-xl border border-border bg-surface-subtle/50 p-3">
|
||||
<p className="mb-1.5 text-body-sm font-medium text-ink-2">Icon</p>
|
||||
<p className="mb-1.5 text-body-sm font-medium text-ink-2">{t('agentConfigure.icon')}</p>
|
||||
<IconPicker
|
||||
icons={AGENT_ICONS}
|
||||
value={fields.icon}
|
||||
@@ -307,8 +309,8 @@ export function AgentConfigure({
|
||||
<DocSection
|
||||
id="instructions"
|
||||
icon={FileText}
|
||||
title="Instructions"
|
||||
description="How this agent should answer, what it is responsible for, and what it should say when it cannot help."
|
||||
title={t('agentConfigure.instructions')}
|
||||
description={t('agentConfigure.howThisAgentShouldAnswerWhat')}
|
||||
meta={`${instructionWords} word${instructionWords === 1 ? '' : 's'}`}
|
||||
open={open.instructions}
|
||||
onToggle={() => toggleSection('instructions')}
|
||||
@@ -333,7 +335,7 @@ export function AgentConfigure({
|
||||
<span className="text-caption tabular-nums text-ink-4">
|
||||
{instructionWords} word{instructionWords === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span className="text-caption text-ink-4">Saved with the agent definition</span>
|
||||
<span className="text-caption text-ink-4">{t('agentConfigure.savedWithTheAgentDefinition')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DocSection>
|
||||
@@ -342,8 +344,8 @@ export function AgentConfigure({
|
||||
<DocSection
|
||||
id="capabilities"
|
||||
icon={Boxes}
|
||||
title="Capabilities"
|
||||
description="What this agent can reach and use when it answers."
|
||||
title={t('agentConfigure.capabilities')}
|
||||
description={t('agentConfigure.whatThisAgentCanReachAnd')}
|
||||
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
|
||||
open={open.capabilities}
|
||||
onToggle={() => toggleSection('capabilities')}
|
||||
@@ -354,12 +356,12 @@ export function AgentConfigure({
|
||||
<GroupHead
|
||||
id="capabilities-pages"
|
||||
icon={LayoutGrid}
|
||||
title="Pages it covers"
|
||||
title={t('agentConfigure.pagesItCovers')}
|
||||
count={fields.pages.length}
|
||||
/>
|
||||
<p className="pb-2 text-caption leading-relaxed text-ink-3">
|
||||
{coveredLabels.length
|
||||
? <>Answers on <span className="font-medium text-ink-1">{coveredLabels.join(', ')}</span>. Anywhere else it is shown as constrained and declines.</>
|
||||
? <>{t('agentConfigure.answersOn')} <span className="font-medium text-ink-1">{coveredLabels.join(', ')}</span>{t('agentConfigure.anywhereElseItIsShownAs')}</>
|
||||
: 'No page selected — this agent has nowhere to answer yet.'}
|
||||
</p>
|
||||
{/**
|
||||
@@ -390,7 +392,7 @@ export function AgentConfigure({
|
||||
</span>
|
||||
))}
|
||||
{!fields.pages.length && (
|
||||
<span className="text-body-sm text-ink-3">No surface registered.</span>
|
||||
<span className="text-body-sm text-ink-3">{t('agentConfigure.noSurfaceRegistered')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -436,19 +438,18 @@ export function AgentConfigure({
|
||||
<GroupHead
|
||||
id="capabilities-skills"
|
||||
icon={Boxes}
|
||||
title="Skills"
|
||||
title={t('agentConfigure.skills')}
|
||||
count={fields.skills.length}
|
||||
action={onOpenSkills && (
|
||||
<Button variant="ghost" size="sm" onClick={onOpenSkills}>
|
||||
Browse catalog
|
||||
{t('agentConfigure.browseCatalog')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!fields.pages.length ? (
|
||||
<p className="mt-2 text-body-sm text-ink-3">
|
||||
Choose a page first. Skills belong to pages, so there are none to offer until
|
||||
this agent has somewhere to answer.
|
||||
{t('agentConfigure.chooseAPageFirstSkillsBelong')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-3">
|
||||
@@ -478,7 +479,7 @@ export function AgentConfigure({
|
||||
|
||||
{!fields.skills.length && (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No skills attached. This agent answers from the page's own reader only.
|
||||
{t('agentConfigure.noSkillsAttachedThisAgentAnswers')}only.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -523,7 +524,7 @@ export function AgentConfigure({
|
||||
<GroupHead
|
||||
id="capabilities-tools"
|
||||
icon={Wrench}
|
||||
title="Tools"
|
||||
title={t('agentConfigure.tools')}
|
||||
count={fields.tools.length}
|
||||
/>
|
||||
|
||||
@@ -557,8 +558,7 @@ export function AgentConfigure({
|
||||
|
||||
{!fields.tools.length && (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No tools attached. This agent can discuss its subject but cannot look
|
||||
anything up.
|
||||
{t('agentConfigure.noToolsAttachedThisAgentCan')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -584,9 +584,7 @@ export function AgentConfigure({
|
||||
</Select>
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
A tool marked <em>writes</em> can propose a change. Nothing is written until
|
||||
somebody approves it, and the agent can only reach what you could reach
|
||||
yourself.
|
||||
{t('agentConfigure.aToolMarked')} <em>writes</em> {t('agentConfigure.canProposeAChangeNothingIs')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -596,7 +594,7 @@ export function AgentConfigure({
|
||||
<GroupHead
|
||||
id="capabilities-knowledge"
|
||||
icon={BookOpen}
|
||||
title="Knowledge"
|
||||
title={t('agentConfigure.knowledge')}
|
||||
count={fields.knowledge.length}
|
||||
action={(
|
||||
<Button
|
||||
@@ -609,7 +607,7 @@ export function AgentConfigure({
|
||||
],
|
||||
})}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add knowledge
|
||||
<Plus className="h-3.5 w-3.5" /> {t('agentConfigure.addKnowledge')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -622,10 +620,10 @@ export function AgentConfigure({
|
||||
className="animate-fade-in space-y-2.5 rounded-xl border border-border bg-surface-subtle p-3"
|
||||
>
|
||||
<div className="grid gap-2.5 sm:grid-cols-[minmax(0,1fr)_9rem]">
|
||||
<Field label="Title">
|
||||
<Field label={t('agentConfigure.title')}>
|
||||
<Input
|
||||
value={entry.label}
|
||||
placeholder="e.g. Overtime policy"
|
||||
placeholder={t('agentConfigure.eGOvertimePolicy')}
|
||||
onChange={(e) => {
|
||||
const next = [...fields.knowledge];
|
||||
next[i] = { ...entry, label: e.target.value };
|
||||
@@ -633,7 +631,7 @@ export function AgentConfigure({
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kind">
|
||||
<Field label={t('agentConfigure.kind')}>
|
||||
<Select
|
||||
value={entry.kind}
|
||||
onValueChange={(kind) => {
|
||||
@@ -655,7 +653,7 @@ export function AgentConfigure({
|
||||
</div>
|
||||
|
||||
{entry.kind === 'link' ? (
|
||||
<Field label="Link">
|
||||
<Field label={t('agentConfigure.link')}>
|
||||
<Input
|
||||
value={entry.url}
|
||||
placeholder="https://…"
|
||||
@@ -668,8 +666,8 @@ export function AgentConfigure({
|
||||
</Field>
|
||||
) : (
|
||||
<Field
|
||||
label="What it says"
|
||||
hint="Quoted back with its source named. Nothing is invented from it."
|
||||
label={t('agentConfigure.whatItSays')}
|
||||
hint={t('agentConfigure.quotedBackWithItsSourceNamed')}
|
||||
>
|
||||
<Textarea
|
||||
value={entry.body}
|
||||
@@ -690,14 +688,14 @@ export function AgentConfigure({
|
||||
knowledge: fields.knowledge.filter((_, x) => x !== i),
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Remove
|
||||
<Trash2 className="h-3.5 w-3.5" /> {t('agentConfigure.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No knowledge attached. Nothing is invented — this agent answers from skills only.
|
||||
{t('agentConfigure.noKnowledgeAttachedNothingIsInvented')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -708,8 +706,8 @@ export function AgentConfigure({
|
||||
<DocSection
|
||||
id="behavior"
|
||||
icon={SlidersHorizontal}
|
||||
title="Behavior"
|
||||
description="How this agent works when it answers."
|
||||
title={t('agentConfigure.behavior')}
|
||||
description={t('agentConfigure.howThisAgentWorksWhenIt')}
|
||||
meta={reasoning?.label}
|
||||
open={open.behavior}
|
||||
onToggle={() => toggleSection('behavior')}
|
||||
@@ -719,7 +717,7 @@ export function AgentConfigure({
|
||||
<SettingRow
|
||||
id="behavior-reasoning"
|
||||
icon={Brain}
|
||||
title="Reasoning"
|
||||
title={t('agentConfigure.reasoning')}
|
||||
description={reasoning?.summary || 'How much work an answer is worth.'}
|
||||
control={(
|
||||
<SegmentedToggle
|
||||
@@ -735,9 +733,9 @@ export function AgentConfigure({
|
||||
<SettingRow
|
||||
id="behavior-starters"
|
||||
icon={MessageSquare}
|
||||
title="Conversation starters"
|
||||
title={t('agentConfigure.conversationStarters')}
|
||||
value={starterCount ? `${starterCount} configured` : 'None'}
|
||||
description="Questions offered as chips when this agent opens."
|
||||
description={t('agentConfigure.questionsOfferedAsChipsWhenThis')}
|
||||
open={setting === 'behavior-starters'}
|
||||
onToggle={() => toggleSetting('behavior-starters')}
|
||||
>
|
||||
@@ -746,7 +744,7 @@ export function AgentConfigure({
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={starter.label}
|
||||
placeholder="e.g. Which roles are at risk?"
|
||||
placeholder={t('agentConfigure.eGWhichRolesAreAt')}
|
||||
className="max-w-[30rem]"
|
||||
onChange={(e) => {
|
||||
const next = [...fields.starters];
|
||||
@@ -757,7 +755,7 @@ export function AgentConfigure({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Remove starter"
|
||||
aria-label={t('agentConfigure.removeStarter')}
|
||||
onClick={() => set({ starters: fields.starters.filter((_, x) => x !== i) })}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
@@ -769,7 +767,7 @@ export function AgentConfigure({
|
||||
size="sm"
|
||||
onClick={() => set({ starters: [...fields.starters, { label: '', prompt: '' }] })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add starter
|
||||
<Plus className="h-3.5 w-3.5" /> {t('agentConfigure.addStarter')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
@@ -777,22 +775,21 @@ export function AgentConfigure({
|
||||
<div id="behavior-web" className="scroll-mt-28">
|
||||
<SettingRow
|
||||
icon={Globe}
|
||||
title="Web search"
|
||||
title={t('agentConfigure.webSearch')}
|
||||
value={fields.webSearch ? 'On' : 'Off'}
|
||||
description="Whether this agent may look outside the workspace."
|
||||
description={t('agentConfigure.whetherThisAgentMayLookOutside')}
|
||||
control={(
|
||||
<Switch
|
||||
checked={fields.webSearch}
|
||||
onCheckedChange={(webSearch) => set({ webSearch })}
|
||||
aria-label="Allow web search"
|
||||
aria-label={t('agentConfigure.allowWebSearch')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Collapse open={fields.webSearch}>
|
||||
<div className="pb-4">
|
||||
<Alert tone="warning" title="No search provider is configured">
|
||||
This deployment has no web search connected, so answers say plainly that
|
||||
nothing outside the workspace was consulted.
|
||||
<Alert tone="warning" title={t('agentConfigure.noSearchProviderIsConfigured')}>
|
||||
{t('agentConfigure.thisDeploymentHasNoWebSearch')}
|
||||
</Alert>
|
||||
</div>
|
||||
</Collapse>
|
||||
@@ -801,9 +798,9 @@ export function AgentConfigure({
|
||||
<SettingRow
|
||||
id="behavior-subagents"
|
||||
icon={Layers}
|
||||
title="Subagents"
|
||||
title={t('agentConfigure.subagents')}
|
||||
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'}
|
||||
description="Other agents this one may hand a question to and report back from. Most need none."
|
||||
description={t('agentConfigure.otherAgentsThisOneMayHand')}
|
||||
open={setting === 'behavior-subagents'}
|
||||
onToggle={() => toggleSetting('behavior-subagents')}
|
||||
last
|
||||
@@ -836,7 +833,7 @@ export function AgentConfigure({
|
||||
&& set({ subagents: [...fields.subagents, id] })}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-72">
|
||||
<SelectValue placeholder="Add a subagent..." />
|
||||
<SelectValue placeholder={t('agentConfigure.addASubagent')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSubagents
|
||||
@@ -848,9 +845,7 @@ export function AgentConfigure({
|
||||
</Select>
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
A subagent runs as you, with the same access you have and out of the same
|
||||
budget as the question that reached it — so delegating cannot read anything
|
||||
you could not read yourself, and cannot buy more time by asking again.
|
||||
{t('agentConfigure.aSubagentRunsAsYouWith')}
|
||||
</p>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { conversationStats, reviewRow } from '@/lib/agents/conversationInsights'
|
||||
import { allSkills } from '@/lib/skills/registry';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* What this agent has actually been asked.
|
||||
@@ -45,6 +46,7 @@ function MetricCard({ icon: Icon, label, value, detail, highlight }: any) {
|
||||
}
|
||||
|
||||
export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
const { t } = useTranslation();
|
||||
const preferences = usePreferences();
|
||||
|
||||
const [records] = React.useState(() => readHistory());
|
||||
@@ -72,7 +74,7 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
<div className="w-full rounded-2xl border border-border bg-surface p-12 shadow-xs text-center">
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
title="No conversations with this agent yet"
|
||||
title={t('agentInsightsPanel.noConversationsWithThisAgentYet')}
|
||||
description={`Insights are counted from conversations held on this device. Ask ${agentName || 'this agent'} something in Owliver and the figures appear here.`}
|
||||
/>
|
||||
</div>
|
||||
@@ -85,25 +87,25 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={MessageSquare}
|
||||
label="Total Conversations"
|
||||
label={t('agentInsightsPanel.totalConversations')}
|
||||
value={stats.total}
|
||||
detail={`Active across ${stats.activeDays} day${stats.activeDays === 1 ? '' : 's'}`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={HelpCircle}
|
||||
label="Questions Asked"
|
||||
label={t('agentInsightsPanel.questionsAsked')}
|
||||
value={stats.turns}
|
||||
detail={`${stats.averageTurns} per session average`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Activity}
|
||||
label="Surfaces Utilized"
|
||||
label={t('agentInsightsPanel.surfacesUtilized')}
|
||||
value={stats.pages}
|
||||
detail="Operational pages touched"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={ThumbsUp}
|
||||
label="Feedback Rating"
|
||||
label={t('agentInsightsPanel.feedbackRating')}
|
||||
value={stats.feedback.score === null ? '—' : `${stats.feedback.score}%`}
|
||||
detail={
|
||||
stats.feedback.score === null
|
||||
@@ -119,7 +121,7 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-krow-blue" />
|
||||
<h4 className="font-heading text-body font-semibold text-ink-1">Most Triggered Skills</h4>
|
||||
<h4 className="font-heading text-body font-semibold text-ink-1">{t('agentInsightsPanel.mostTriggeredSkills')}</h4>
|
||||
</div>
|
||||
|
||||
{stats.bySkill.length ? (
|
||||
@@ -138,7 +140,7 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
|
||||
No skill executions logged yet. Conversations were handled directly by native page readers.
|
||||
{t('agentInsightsPanel.noSkillExecutionsLoggedYetConversations')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -147,7 +149,7 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4 text-krow-blue" />
|
||||
<h4 className="font-heading text-body font-semibold text-ink-1">Actions & Tool Usage</h4>
|
||||
<h4 className="font-heading text-body font-semibold text-ink-1">{t('agentInsightsPanel.actionsToolUsage')}</h4>
|
||||
</div>
|
||||
|
||||
{stats.byTool.length ? (
|
||||
@@ -166,7 +168,7 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
|
||||
No action tool executions recorded in current audit window.
|
||||
{t('agentInsightsPanel.noActionToolExecutionsRecordedIn')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -178,7 +180,7 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-krow-blue" />
|
||||
<h4 className="font-heading text-body font-semibold text-ink-1">
|
||||
Recent Conversation Logs
|
||||
{t('agentInsightsPanel.recentConversationLogs')}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,7 +196,7 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
{row.title || 'General consultation'}
|
||||
</p>
|
||||
<p className="text-caption text-ink-4">
|
||||
Surface:{' '}
|
||||
{t('agentInsightsPanel.surface')}{' '}
|
||||
<span className="font-medium text-ink-3">{row.page || 'Direct'}</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -215,11 +217,11 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||
>
|
||||
{row.feedback.rating === 'up' ? (
|
||||
<>
|
||||
<ThumbsUp className="h-2.5 w-2.5" /> Helpful
|
||||
<ThumbsUp className="h-2.5 w-2.5" /> {t('agentInsightsPanel.helpful')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ThumbsDown className="h-2.5 w-2.5" /> Unhelpful
|
||||
<ThumbsDown className="h-2.5 w-2.5" /> {t('agentInsightsPanel.unhelpful')}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { agentCovers, agentScopedDisabled, classifyQuestion } from '@/lib/agents
|
||||
import { toolsForContext } from '@/lib/skills/tools';
|
||||
import { retrieveKnowledge } from '@/lib/agents/knowledge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* What this agent would do, on a page, before it is published.
|
||||
@@ -47,6 +48,7 @@ const CLASSIFICATION_COPY = {
|
||||
};
|
||||
|
||||
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }: any) {
|
||||
const { t } = useTranslation();
|
||||
const [contextId, setContextId] = React.useState(
|
||||
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|
||||
|| CONTEXT_OPTIONS[0].contextId
|
||||
@@ -102,10 +104,10 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-heading text-body font-semibold text-ink-1">
|
||||
Simulation & Scope Diagnostics
|
||||
{t('agentTestPanel.simulationScopeDiagnostics')}
|
||||
</h3>
|
||||
<p className="text-caption text-ink-4">
|
||||
Evaluate real-time intent routing and skill activation without publishing changes.
|
||||
{t('agentTestPanel.evaluateRealTimeIntentRoutingAnd')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,7 +115,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
{dirty && (
|
||||
<Badge variant="warning" size="sm" className="gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning animate-pulse" />
|
||||
Testing unsaved draft
|
||||
{t('agentTestPanel.testingUnsavedDraft')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -123,7 +125,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
<div className="grid gap-4 md:grid-cols-12 items-end">
|
||||
<div className="md:col-span-4 space-y-1.5">
|
||||
<label className="block text-body-sm font-medium text-ink-2">
|
||||
Contextual Page
|
||||
{t('agentTestPanel.contextualPage')}
|
||||
</label>
|
||||
<Select value={contextId} onValueChange={setContextId}>
|
||||
<SelectTrigger className="w-full bg-surface">
|
||||
@@ -141,14 +143,14 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
|
||||
<div className="md:col-span-8 space-y-1.5">
|
||||
<label className="block text-body-sm font-medium text-ink-2">
|
||||
Simulated User Query
|
||||
{t('agentTestPanel.simulatedUserQuery')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-4" />
|
||||
<Input
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="e.g. Which roles are at risk?"
|
||||
placeholder={t('agentTestPanel.eGWhichRolesAreAt')}
|
||||
className="pl-9 w-full bg-surface"
|
||||
/>
|
||||
</div>
|
||||
@@ -176,7 +178,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
Ask Owliver for real
|
||||
{t('agentTestPanel.askOwliverForReal')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -185,7 +187,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
{fields.starters.filter((s) => s.label).length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-border/50">
|
||||
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">
|
||||
Suggested Starters:
|
||||
{t('agentTestPanel.suggestedStarters')}
|
||||
</span>
|
||||
{fields.starters.filter((s) => s.label).map((s, i) => (
|
||||
<button
|
||||
@@ -248,7 +250,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2 text-ink-1">
|
||||
<Cpu className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Intent & Skill Routing</h4>
|
||||
<h4 className="font-heading text-body font-semibold">{t('agentTestPanel.intentSkillRouting')}</h4>
|
||||
</div>
|
||||
<p className="text-caption text-ink-3">
|
||||
{CLASSIFICATION_COPY[result.classification] || CLASSIFICATION_COPY.structured}
|
||||
@@ -260,7 +262,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
<span className="text-body-sm font-semibold text-krow-blue">
|
||||
{result.matched.name}
|
||||
</span>
|
||||
<Badge variant="info" size="sm">Matched</Badge>
|
||||
<Badge variant="info" size="sm">{t('agentTestPanel.matched')}</Badge>
|
||||
</div>
|
||||
<p className="text-caption text-ink-3 leading-relaxed">
|
||||
{result.matched.description}
|
||||
@@ -268,7 +270,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||
No specialized skill claimed this exact wording. The native page reader will process the query.
|
||||
{t('agentTestPanel.noSpecializedSkillClaimedThisExact')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -278,7 +280,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
<div className="flex items-center justify-between gap-2 text-ink-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Reachable Page Skills</h4>
|
||||
<h4 className="font-heading text-body font-semibold">{t('agentTestPanel.reachablePageSkills')}</h4>
|
||||
</div>
|
||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
|
||||
{result.scoped.length}
|
||||
@@ -293,13 +295,13 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
|
||||
>
|
||||
<span className="truncate font-medium text-ink-2">{s.name}</span>
|
||||
<span className="text-caption text-ink-4 tabular-nums">Ready</span>
|
||||
<span className="text-caption text-ink-4 tabular-nums">{t('agentTestPanel.ready')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||
No attached skills apply to {activePageLabel}.
|
||||
{t('agentTestPanel.noAttachedSkillsApplyTo')} {activePageLabel}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -309,7 +311,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
<div className="flex items-center justify-between gap-2 text-ink-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Execution Actions & Tools</h4>
|
||||
<h4 className="font-heading text-body font-semibold">{t('agentTestPanel.executionActionsTools')}</h4>
|
||||
</div>
|
||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
|
||||
{result.tools.length}
|
||||
@@ -325,16 +327,16 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
>
|
||||
<span className="truncate font-medium text-ink-2">{tool.label}</span>
|
||||
{tool.requiresApproval ? (
|
||||
<Badge variant="warning" size="sm">Confirmation required</Badge>
|
||||
<Badge variant="warning" size="sm">{t('agentTestPanel.confirmationRequired')}</Badge>
|
||||
) : (
|
||||
<Badge variant="neutral" size="sm">Read-only</Badge>
|
||||
<Badge variant="neutral" size="sm">{t('agentTestPanel.readOnly')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||
Read-only operations. No active mutations or system write actions.
|
||||
{t('agentTestPanel.readOnlyOperationsNoActiveMutations')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -343,7 +345,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||
<div className="flex items-center gap-2 text-ink-1">
|
||||
<BookOpen className="h-4 w-4 text-krow-blue shrink-0" />
|
||||
<h4 className="font-heading text-body font-semibold">Referenced Knowledge</h4>
|
||||
<h4 className="font-heading text-body font-semibold">{t('agentTestPanel.referencedKnowledge')}</h4>
|
||||
</div>
|
||||
|
||||
{result.knowledge.passages.length ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AgentTree } from './AgentTree';
|
||||
import { BoardSkillList } from './BoardSkillList';
|
||||
import { SkillCatalog } from './SkillCatalog';
|
||||
import { SkillDetails } from './SkillDetails';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Composing an agent: what it is made of, beside what it could be made of.
|
||||
@@ -55,6 +56,7 @@ export function AgentSkillWorkspace({
|
||||
pendingId = null,
|
||||
dirty = false,
|
||||
}: any) {
|
||||
const { t } = useTranslation();
|
||||
const [kind, setKind] = React.useState(OWLIVER);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [boardQuery, setBoardQuery] = React.useState('');
|
||||
@@ -295,16 +297,14 @@ export function AgentSkillWorkspace({
|
||||
return (
|
||||
<div className="w-full min-w-0 space-y-3">
|
||||
{dirty && (
|
||||
<Alert tone="info" title="This agent has unsaved edits">
|
||||
Skill changes are being kept in the draft with them. Save the agent to store everything
|
||||
together.
|
||||
<Alert tone="info" title={t('agentSkillWorkspace.thisAgentHasUnsavedEdits')}>
|
||||
{t('agentSkillWorkspace.skillChangesAreBeingKeptIn')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{unknown.length > 0 && (
|
||||
<Alert tone="warning" title={`${unknown.length} attached skill${unknown.length === 1 ? '' : 's'} not in the registry`}>
|
||||
{unknown.join(', ')} — no definition with that id is registered, so it answers nothing.
|
||||
Remove it from the tree, or restore the definition.
|
||||
{unknown.join(', ')} {t('agentSkillWorkspace.noDefinitionWithThatIdIs')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -339,7 +339,7 @@ export function AgentSkillWorkspace({
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
Agent tree
|
||||
{t('agentSkillWorkspace.agentTree')}
|
||||
<span className="tabular-nums text-ink-4">
|
||||
· {fields.skills.length} skill{fields.skills.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
@@ -407,12 +407,12 @@ export function AgentSkillWorkspace({
|
||||
onOpenChange={setTreeOpen}
|
||||
side="left"
|
||||
size="sm"
|
||||
title="Agent"
|
||||
description="What this agent is made of."
|
||||
title={t('agentSkillWorkspace.agent')}
|
||||
description={t('agentSkillWorkspace.whatThisAgentIsMadeOf')}
|
||||
icon={ListTree}
|
||||
footer={(
|
||||
<Button variant="outline" onClick={() => setTreeOpen(false)} className="w-full">
|
||||
<X className="h-3.5 w-3.5" /> Close
|
||||
<X className="h-3.5 w-3.5" /> {t('agentSkillWorkspace.close')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { glyphForGroup } from './glyphs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* What this agent is made of.
|
||||
@@ -109,6 +110,7 @@ function Branch({ id, icon: Icon, label, count, open, onToggle, children, hint,
|
||||
* skill while trying to look at it.
|
||||
*/
|
||||
function SkillLeaf({ entry, id, selected, onSelect, onRemove, busy }) {
|
||||
const { t } = useTranslation();
|
||||
const Glyph = entry ? glyphForGroup(entry.group) : AlertTriangle;
|
||||
const name = entry?.name || id;
|
||||
|
||||
@@ -140,7 +142,7 @@ function SkillLeaf({ entry, id, selected, onSelect, onRemove, busy }) {
|
||||
cannot separate the tint from the surface can read either. */}
|
||||
<span className="sr-only">{selected ? ' — selected' : ''}</span>
|
||||
{!entry && (
|
||||
<span className="shrink-0 text-[10px] font-medium text-warning">Unknown</span>
|
||||
<span className="shrink-0 text-[10px] font-medium text-warning">{t('agentTree.unknown')}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -191,10 +193,11 @@ export function AgentTree({
|
||||
busy = false,
|
||||
className,
|
||||
}: any) {
|
||||
const { t } = useTranslation();
|
||||
const Glyph = agentIconFor(agentIcon);
|
||||
|
||||
return (
|
||||
<nav aria-label="Agent composition" className={cn('min-w-0', className)}>
|
||||
<nav aria-label={t('agentTree.agentComposition')} className={cn('min-w-0', className)}>
|
||||
{/* The root. Not a disclosure: an agent that could be collapsed to
|
||||
nothing would leave the panel showing a single closed row. */}
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-surface-subtle/60 px-2.5 py-2">
|
||||
@@ -210,7 +213,7 @@ export function AgentTree({
|
||||
<span className="block truncate font-heading text-body-sm font-semibold text-ink-1">
|
||||
{agentName || 'New agent'}
|
||||
</span>
|
||||
<span className="block text-[10px] uppercase tracking-wide text-ink-4">Agent</span>
|
||||
<span className="block text-[10px] uppercase tracking-wide text-ink-4">{t('agentTree.agent')}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -220,11 +223,11 @@ export function AgentTree({
|
||||
<Branch
|
||||
id="surfaces"
|
||||
icon={LayoutGrid}
|
||||
label="Surfaces"
|
||||
label={t('agentTree.surfaces')}
|
||||
count={surfaces.length}
|
||||
open={open.surfaces}
|
||||
onToggle={() => onToggleBranch('surfaces')}
|
||||
hint="No page selected — this agent has nowhere to answer yet."
|
||||
hint={t('agentTree.noPageSelectedThisAgentHas')}
|
||||
>
|
||||
{surfaces.map((surface) => (
|
||||
<StaticLeaf key={surface.id} icon={LayoutGrid} label={surface.label} />
|
||||
@@ -235,20 +238,20 @@ export function AgentTree({
|
||||
<Branch
|
||||
id="skills"
|
||||
icon={Zap}
|
||||
label="Skills"
|
||||
label={t('agentTree.skills')}
|
||||
count={skills.length + boardSkills.length}
|
||||
open={open.skills}
|
||||
onToggle={() => onToggleBranch('skills')}
|
||||
hint="Nothing yet."
|
||||
hint={t('agentTree.nothingYet')}
|
||||
>
|
||||
<Branch
|
||||
id="skills-owliver"
|
||||
icon={Zap}
|
||||
label="Owliver Skills"
|
||||
label={t('agentTree.owliverSkills')}
|
||||
count={skills.length}
|
||||
open={open['skills-owliver']}
|
||||
onToggle={() => onToggleBranch('skills-owliver')}
|
||||
hint="Nothing attached yet. Add one from the catalog."
|
||||
hint={t('agentTree.nothingAttachedYetAddOneFrom')}
|
||||
sub
|
||||
>
|
||||
{skills.map((id) => (
|
||||
@@ -270,11 +273,11 @@ export function AgentTree({
|
||||
<Branch
|
||||
id="skills-board"
|
||||
icon={LayoutTemplate}
|
||||
label="Board Skills"
|
||||
label={t('agentTree.boardSkills')}
|
||||
count={boardSkills.length}
|
||||
open={open['skills-board']}
|
||||
onToggle={() => onToggleBranch('skills-board')}
|
||||
hint="None drawing on this agent's surfaces."
|
||||
hint={t('agentTree.noneDrawingOnThisAgentsSurfaces')}
|
||||
sub
|
||||
>
|
||||
{boardSkills.map((entry) => (
|
||||
@@ -291,11 +294,11 @@ export function AgentTree({
|
||||
<Branch
|
||||
id="tools"
|
||||
icon={Wrench}
|
||||
label="Tools"
|
||||
label={t('agentTree.tools')}
|
||||
count={tools.length}
|
||||
open={open.tools}
|
||||
onToggle={() => onToggleBranch('tools')}
|
||||
hint="Tools arrive with skills. Attach a skill that declares one."
|
||||
hint={t('agentTree.toolsArriveWithSkillsAttachA')}
|
||||
>
|
||||
{tools.map((tool) => (
|
||||
<StaticLeaf key={tool.name} icon={Wrench} label={tool.label} detail={tool.via} />
|
||||
@@ -305,11 +308,11 @@ export function AgentTree({
|
||||
<Branch
|
||||
id="knowledge"
|
||||
icon={BookOpen}
|
||||
label="Knowledge"
|
||||
label={t('agentTree.knowledge')}
|
||||
count={knowledge.length}
|
||||
open={open.knowledge}
|
||||
onToggle={() => onToggleBranch('knowledge')}
|
||||
hint="Nothing attached. Add knowledge under Configure."
|
||||
hint={t('agentTree.nothingAttachedAddKnowledgeUnderConfigure')}
|
||||
>
|
||||
{knowledge.map((entry, i) => (
|
||||
<StaticLeaf
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LayoutTemplate, Minus, Pencil, Plus, SearchX } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Board skills, under the agent that stands beside them.
|
||||
@@ -33,6 +34,7 @@ import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
||||
|
||||
/** One Board skill: what it draws, where, and whether it is switched on. */
|
||||
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) {
|
||||
const { t } = useTranslation();
|
||||
const switchId = `board-${entry.id}`;
|
||||
|
||||
return (
|
||||
@@ -66,7 +68,7 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = fals
|
||||
<LayoutTemplate className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
|
||||
Board Skill
|
||||
{t('boardSkillList.boardSkill')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -135,8 +137,8 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = fals
|
||||
aria-label={`${attached ? 'Remove' : 'Add'} ${entry.name} ${attached ? 'from' : 'to'} this agent`}
|
||||
>
|
||||
{attached
|
||||
? <><Minus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove</>
|
||||
: <><Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add to agent</>}
|
||||
? <><Minus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />{t('boardSkillList.remove')}</>
|
||||
: <><Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />{t('boardSkillList.addToAgent')}</>}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -151,7 +153,7 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = fals
|
||||
to={`/admin/workspace/skills/${entry.id}`}
|
||||
className="inline-flex items-center gap-1 text-caption font-semibold text-krow-blue hover:text-krow-blue-dark transition-colors"
|
||||
>
|
||||
<Pencil className="h-3 w-3" aria-hidden="true" /> Edit
|
||||
<Pencil className="h-3 w-3" aria-hidden="true" /> {t('boardSkillList.edit')}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
@@ -163,6 +165,7 @@ export function BoardSkillList({
|
||||
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
|
||||
attachedIds = null, onAttach = null,
|
||||
}: any) {
|
||||
const { t } = useTranslation();
|
||||
const searchId = React.useId();
|
||||
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
|
||||
|
||||
@@ -177,22 +180,19 @@ export function BoardSkillList({
|
||||
}, [entries, onSurfaces]);
|
||||
|
||||
return (
|
||||
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4">
|
||||
<Alert tone="info" title="Two switches, two different questions">
|
||||
<strong>Active</strong> is workspace-wide: off means off on every page, for every
|
||||
agent. <strong>Add to agent</strong> is ownership — once any agent owns a board
|
||||
skill, it draws only where an owning agent is answering. A skill no agent owns
|
||||
keeps drawing wherever its pages say, as before.
|
||||
<section aria-label={t('boardSkillList.boardSkills')} className="flex min-w-0 flex-col gap-4">
|
||||
<Alert tone="info" title={t('boardSkillList.twoSwitchesTwoDifferentQuestions')}>
|
||||
<strong>{t('boardSkillList.active')}</strong> {t('boardSkillList.isWorkspaceWideOffMeansOff')} <strong>{t('boardSkillList.addToAgent')}</strong> {t('boardSkillList.isOwnershipOnceAnyAgentOwns')}
|
||||
</Alert>
|
||||
|
||||
{total > 0 && (
|
||||
<div>
|
||||
<label htmlFor={searchId} className="sr-only">Search board skills</label>
|
||||
<label htmlFor={searchId} className="sr-only">{t('boardSkillList.searchBoardSkills')}</label>
|
||||
<SearchInput
|
||||
id={searchId}
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
placeholder="Search board skills, sections or pages..."
|
||||
placeholder={t('boardSkillList.searchBoardSkillsSectionsOrPages')}
|
||||
debounce={0}
|
||||
/>
|
||||
</div>
|
||||
@@ -201,7 +201,7 @@ export function BoardSkillList({
|
||||
{total > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 pt-1">
|
||||
<p className="text-xs font-medium text-ink-3" aria-live="polite">
|
||||
<span className="font-semibold text-ink-1">{ordered.length}</span> of {total} board skill{total === 1 ? '' : 's'}
|
||||
<span className="font-semibold text-ink-1">{ordered.length}</span> of {total} {t('boardSkillList.boardSkill2')}{total === 1 ? '' : 's'}
|
||||
</p>
|
||||
<Link
|
||||
to="/admin/workspace/skills/new"
|
||||
@@ -210,7 +210,7 @@ export function BoardSkillList({
|
||||
hover:bg-krow-blue-tint/60 hover:border-krow-blue/40 focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> New board skill
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> {t('boardSkillList.newBoardSkill')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -235,20 +235,19 @@ export function BoardSkillList({
|
||||
{total > 0 ? (
|
||||
<>
|
||||
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
||||
<p className="mt-2.5 text-sm font-semibold text-ink-1">No board skills match that</p>
|
||||
<p className="mt-1 text-xs text-ink-3">Try a different word.</p>
|
||||
<p className="mt-2.5 text-sm font-semibold text-ink-1">{t('boardSkillList.noBoardSkillsMatchThat')}</p>
|
||||
<p className="mt-1 text-xs text-ink-3">{t('boardSkillList.tryADifferentWord')}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LayoutTemplate className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
||||
<p className="mt-2.5 text-sm font-semibold text-ink-1">No board skills yet</p>
|
||||
<p className="mt-2.5 text-sm font-semibold text-ink-1">{t('boardSkillList.noBoardSkillsYet')}</p>
|
||||
<p className="mx-auto mt-1 max-w-[46ch] text-xs leading-relaxed text-ink-3">
|
||||
A board skill declares a section, where it sits on a page, and what it reads.
|
||||
Add one and the page it names renders it.
|
||||
{t('boardSkillList.aBoardSkillDeclaresASection')}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-3.5">
|
||||
<Link to="/admin/workspace/skills/new">
|
||||
<Plus className="h-3.5 w-3.5" /> Add a board skill
|
||||
<Plus className="h-3.5 w-3.5" /> {t('boardSkillList.addABoardSkill')}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SearchInput } from '@/components/ds';
|
||||
import { SkillCard } from './SkillCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The catalog — every skill this workspace has, browsable.
|
||||
@@ -24,10 +25,11 @@ import { SkillCard } from './SkillCard';
|
||||
*/
|
||||
|
||||
function CategoryFilter({ groups, value, onChange, total }: any) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Filter skills by category"
|
||||
aria-label={t('skillCatalog.filterSkillsByCategory')}
|
||||
className="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-x-visible"
|
||||
>
|
||||
{[{ id: 'all', label: 'All', count: total }, ...groups].map((group) => {
|
||||
@@ -75,18 +77,19 @@ export function SkillCatalog({
|
||||
busy = false,
|
||||
pendingId = null,
|
||||
}: any) {
|
||||
const { t } = useTranslation();
|
||||
const searchId = React.useId();
|
||||
|
||||
return (
|
||||
<section aria-label="Skill catalog" className="flex min-w-0 flex-col gap-4">
|
||||
<section aria-label={t('skillCatalog.skillCatalog')} className="flex min-w-0 flex-col gap-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor={searchId} className="sr-only">Search skills</label>
|
||||
<label htmlFor={searchId} className="sr-only">{t('skillCatalog.searchSkills')}</label>
|
||||
<SearchInput
|
||||
id={searchId}
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
placeholder="Search skills, capabilities or pages..."
|
||||
placeholder={t('skillCatalog.searchSkillsCapabilitiesOrPages')}
|
||||
debounce={0}
|
||||
/>
|
||||
</div>
|
||||
@@ -98,7 +101,7 @@ export function SkillCatalog({
|
||||
{/* The count is read from the same array the cards come from, so a
|
||||
heading cannot claim six above four cards. */}
|
||||
<p className="text-caption font-medium text-ink-3" aria-live="polite">
|
||||
<span className="font-bold text-ink-1">{entries.length}</span> of {total} capabilit{total === 1 ? 'y' : 'ies'} for this scope
|
||||
<span className="font-bold text-ink-1">{entries.length}</span> of {total} capabilit{total === 1 ? 'y' : 'ies'} {t('skillCatalog.forThisScope')}
|
||||
{attachedIds.size > 0 && <span className="font-semibold text-krow-blue"> · {attachedIds.size} enabled</span>}
|
||||
</p>
|
||||
|
||||
@@ -110,7 +113,7 @@ export function SkillCatalog({
|
||||
hover:border-krow-blue/40 hover:bg-krow-blue-tint focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> New skill
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> {t('skillCatalog.newSkill')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -133,9 +136,9 @@ export function SkillCatalog({
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-surface-subtle/50 px-4 py-12 text-center">
|
||||
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
||||
<p className="mt-2.5 text-body-sm font-semibold text-ink-1">No skills match that</p>
|
||||
<p className="mt-2.5 text-body-sm font-semibold text-ink-1">{t('skillCatalog.noSkillsMatchThat')}</p>
|
||||
<p className="mt-1 text-caption text-ink-3">
|
||||
Try a different word, or widen the category filter.
|
||||
{t('skillCatalog.tryADifferentWordOrWiden')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
||||
import { testTargets } from '@/lib/agents/capabilityTest';
|
||||
import { SurfaceSelect, supportedSurfaces } from './SurfaceSelect';
|
||||
import { glyphForGroup } from './glyphs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* One capability, configured — without leaving the workspace.
|
||||
@@ -86,6 +87,7 @@ export function CapabilityBrief({
|
||||
entry, attached, surface, onSurfaceChange, question, onQuestionChange,
|
||||
canTest, target, onTest, test, busy = false, showTest = true,
|
||||
}: any) {
|
||||
const { t } = useTranslation();
|
||||
const { shapes, described } = entry.capabilities;
|
||||
|
||||
return (
|
||||
@@ -93,12 +95,12 @@ export function CapabilityBrief({
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="neutral">{entry.groupLabel}</Badge>
|
||||
{attached
|
||||
? <Badge variant="success">Enabled for this agent</Badge>
|
||||
: <Badge variant="neutral">Not enabled</Badge>}
|
||||
{entry.custom && <Badge variant="info">Custom</Badge>}
|
||||
? <Badge variant="success">{t('skillDetails.enabledForThisAgent')}</Badge>
|
||||
: <Badge variant="neutral">{t('skillDetails.notEnabled')}</Badge>}
|
||||
{entry.custom && <Badge variant="info">{t('skillDetails.custom')}</Badge>}
|
||||
</div>
|
||||
|
||||
<Section icon={Sparkles} title="Available as">
|
||||
<Section icon={Sparkles} title={t('skillDetails.availableAs')}>
|
||||
<SurfaceSelect type={entry.type} value={surface} onChange={onSurfaceChange} name={entry.name} />
|
||||
</Section>
|
||||
|
||||
@@ -113,15 +115,13 @@ export function CapabilityBrief({
|
||||
* its reach is the workspace rather than this agent.
|
||||
*/}
|
||||
{(surface === 'board' || surface === 'both') && (
|
||||
<Alert tone="warning" title="Board is switched on workspace-wide">
|
||||
Enabling Board here uses the real switch every page surface already reads, so the section
|
||||
genuinely appears — but it appears for the whole workspace, not only for this agent.
|
||||
There is no per-agent Board runtime yet, and this does not pretend there is one.
|
||||
<Alert tone="warning" title={t('skillDetails.boardIsSwitchedOnWorkspaceWide')}>
|
||||
{t('skillDetails.enablingBoardHereUsesTheReal')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{described.length > 0 && (
|
||||
<Section icon={MessageSquare} title="Capabilities" count={described.length}>
|
||||
<Section icon={MessageSquare} title={t('skillDetails.capabilities')} count={described.length}>
|
||||
<ul className="space-y-1">
|
||||
{described.map((line) => (
|
||||
<li key={line} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
|
||||
@@ -134,7 +134,7 @@ export function CapabilityBrief({
|
||||
)}
|
||||
|
||||
{shapes.length > 0 && (
|
||||
<Section icon={MessageSquare} title="Answer shapes" count={shapes.length}>
|
||||
<Section icon={MessageSquare} title={t('skillDetails.answerShapes')} count={shapes.length}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{shapes.map((shape) => (
|
||||
<span
|
||||
@@ -152,7 +152,7 @@ export function CapabilityBrief({
|
||||
One list, not two: these are the same suggestions the panel offers on
|
||||
the page, so the test asks what the product would ask. */}
|
||||
{entry.suggestions.length > 0 && (
|
||||
<Section icon={MessageSquare} title="What you can ask" count={entry.suggestions.length}>
|
||||
<Section icon={MessageSquare} title={t('skillDetails.whatYouCanAsk')} count={entry.suggestions.length}>
|
||||
<ul className="space-y-1">
|
||||
{entry.suggestions.map((s) => {
|
||||
const value = s.prompt || s.label;
|
||||
@@ -174,7 +174,7 @@ export function CapabilityBrief({
|
||||
)}
|
||||
>
|
||||
“{s.label}”
|
||||
{picked && <span className="sr-only"> — chosen for the test</span>}
|
||||
{picked && <span className="sr-only"> {t('skillDetails.chosenForTheTest')}</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
@@ -183,7 +183,7 @@ export function CapabilityBrief({
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section icon={LayoutGrid} title="Answers on" count={entry.surfaces.length}>
|
||||
<Section icon={LayoutGrid} title={t('skillDetails.answersOn')} count={entry.surfaces.length}>
|
||||
{entry.surfaces.length > 0 ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -197,17 +197,16 @@ export function CapabilityBrief({
|
||||
))}
|
||||
</div>
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
A skill still only answers on the pages it names. Enabling it cannot widen that —
|
||||
the agent's own surfaces narrow it further.
|
||||
{t('skillDetails.aSkillStillOnlyAnswersOn')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">This skill names no page, so it answers nowhere.</p>
|
||||
<p className="text-body-sm text-ink-3">{t('skillDetails.thisSkillNamesNoPageSo')}</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{entry.tools.length > 0 && (
|
||||
<Section icon={Wrench} title="Tools it reaches" count={entry.tools.length}>
|
||||
<Section icon={Wrench} title={t('skillDetails.toolsItReaches')} count={entry.tools.length}>
|
||||
<ul className="space-y-1.5">
|
||||
{entry.tools.map((tool) => (
|
||||
<li key={tool.name} className="rounded-lg border border-border bg-surface px-2.5 py-2">
|
||||
@@ -215,7 +214,7 @@ export function CapabilityBrief({
|
||||
{tool.label}
|
||||
{!tool.readOnly && (
|
||||
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-semibold text-warning">
|
||||
Writes
|
||||
{t('skillDetails.writes')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -231,17 +230,17 @@ export function CapabilityBrief({
|
||||
{/* Test: the button and the outcome. The question and the answer live in
|
||||
the Owliver panel, which is already on this page. */}
|
||||
{showTest && (
|
||||
<Section icon={Play} title="Test">
|
||||
<Section icon={Play} title={t('skillDetails.test')}>
|
||||
<p className="text-caption leading-relaxed text-ink-3">
|
||||
{canTest
|
||||
? <>Runs in the Owliver panel on this page, against <span className="font-medium text-ink-2">{target.label}</span>. Testing changes nothing — the agent is only updated when you enable it.</>
|
||||
? <>{t('skillDetails.runsInTheOwliverPanelOn')} <span className="font-medium text-ink-2">{target.label}</span>{t('skillDetails.testingChangesNothingTheAgentIs')}</>
|
||||
: surface === 'board'
|
||||
? 'A Board section draws on a page rather than answering a question, so there is nothing to ask it here.'
|
||||
: 'This capability answers on no page this agent covers, so there is nothing to test against.'}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onTest} disabled={!canTest || busy} className="w-full">
|
||||
<Play className="h-3.5 w-3.5" /> Test in Owliver
|
||||
<Play className="h-3.5 w-3.5" /> {t('skillDetails.testInOwliver')}
|
||||
</Button>
|
||||
|
||||
<TestState test={test} entryId={entry.id} />
|
||||
@@ -255,6 +254,7 @@ export function SkillDetails({
|
||||
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
|
||||
fields = null, onRunTest = null, test = null,
|
||||
}: any) {
|
||||
const { t } = useTranslation();
|
||||
/**
|
||||
* The surface this would run on.
|
||||
*
|
||||
@@ -308,7 +308,7 @@ export function SkillDetails({
|
||||
size="default"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('skillDetails.close')}</Button>
|
||||
{/* The one control in this product that changes the agent's skills.
|
||||
Last, under everything the decision should rest on. */}
|
||||
<Button
|
||||
@@ -317,8 +317,8 @@ export function SkillDetails({
|
||||
disabled={busy}
|
||||
>
|
||||
{attached
|
||||
? <><Minus className="h-3.5 w-3.5" /> Disable for this agent</>
|
||||
: <><Plus className="h-3.5 w-3.5" /> Enable for this agent</>}
|
||||
? <><Minus className="h-3.5 w-3.5" /> {t('skillDetails.disableForThisAgent')}</>
|
||||
: <><Plus className="h-3.5 w-3.5" /> {t('skillDetails.enableForThisAgent')}</>}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Who is answering, in Owliver's own header.
|
||||
@@ -50,14 +51,16 @@ function AgentGlyph({ agent, className = 'h-7 w-7' }) {
|
||||
* constrained reply arrive with no warning.
|
||||
*/
|
||||
function ConstrainedTag() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300">
|
||||
Constrained
|
||||
{t('agentBadge.constrained')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentBadge({ page }: any) {
|
||||
const { t } = useTranslation();
|
||||
const { agent, covers } = useActiveAgent();
|
||||
|
||||
return (
|
||||
@@ -68,7 +71,7 @@ export function AgentBadge({ page }: any) {
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
|
||||
Owliver
|
||||
{t('agentBadge.owliver')}
|
||||
</h2>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ResponseDocument } from './ResponseBlocks';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The thinking state, shown between sending and the first block arriving.
|
||||
@@ -11,6 +12,7 @@ import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
* reader, and the visually-hidden text is what tells one that a reply is coming.
|
||||
*/
|
||||
export function ThinkingIndicator() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<OwliverAvatar className="h-6 w-6" rounded="rounded-full" />
|
||||
@@ -23,7 +25,7 @@ export function ThinkingIndicator() {
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="sr-only">Owliver is preparing a response</span>
|
||||
<span className="sr-only">{t('assistantMessage.owliverIsPreparingAResponse')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAssistantPanel } from './AssistantPanelContext';
|
||||
import { ResizeDivider } from './ResizeDivider';
|
||||
import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const EXPANDED_WIDTH = 620;
|
||||
/**
|
||||
@@ -33,6 +34,7 @@ const MAX_VIEWPORT_SHARE = 0.42;
|
||||
* in the collapsed state, where there is nowhere else for it to live.
|
||||
*/
|
||||
function CollapsedTrigger({ page, onRestore }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -50,7 +52,7 @@ function CollapsedTrigger({ page, onRestore }) {
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<OwliverAvatar className="h-7 w-7" rounded="rounded-full" />
|
||||
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
|
||||
<span className="text-body-sm font-semibold text-ink-1">{t('assistantPanel.owliver')}</span>
|
||||
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
@@ -81,6 +83,7 @@ function CollapsedTrigger({ page, onRestore }) {
|
||||
* instead of pushing the composer off the bottom of it.
|
||||
*/
|
||||
function MobileWorkspace({ context, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
const viewport = useVisualViewport();
|
||||
|
||||
/* The page behind an overlay must not scroll: on a touch screen a drag that
|
||||
@@ -105,7 +108,7 @@ function MobileWorkspace({ context, onClose }) {
|
||||
pointer. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close the Owliver workspace"
|
||||
aria-label={t('assistant.closeWorkspace')}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-ink-1/30 backdrop-blur-[2px] motion-safe:animate-fade-in"
|
||||
/>
|
||||
@@ -119,7 +122,7 @@ function MobileWorkspace({ context, onClose }) {
|
||||
pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Owliver workspace"
|
||||
aria-label={t('assistant.workspace')}
|
||||
>
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
@@ -180,6 +183,7 @@ function MobileWorkspace({ context, onClose }) {
|
||||
* application layout, never a fullscreen takeover.
|
||||
*/
|
||||
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
context, isOpen, isExpanded, width, minWidth, maxWidth,
|
||||
setWidth, resetWidth, open, close, expand, restore,
|
||||
@@ -221,7 +225,7 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Owliver workspace"
|
||||
aria-label={t('assistant.workspace')}
|
||||
style={{ width: trackWidth }}
|
||||
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2).
|
||||
`min-w-0` — see (3).
|
||||
|
||||
@@ -31,6 +31,8 @@ import { buildIntro } from './dynamic';
|
||||
import { agentScopedDisabled } from '@/lib/agents/runtime';
|
||||
import { buildOwliverContext } from '@/lib/agents/context';
|
||||
import { AgentBadge } from './AgentBadge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLanguage } from '@/lib/i18n/language';
|
||||
import { useActiveAgent } from './AgentContext';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
@@ -65,12 +67,13 @@ const EMPTY_SUGGESTIONS = [];
|
||||
* half-remember.
|
||||
*/
|
||||
function HistoryView({ groups, currentId, onOpen, onForget }) {
|
||||
const { t } = useTranslation();
|
||||
if (!groups.length) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">No conversations yet</p>
|
||||
<p className="font-heading text-body font-semibold text-ink-1">{t('krowAssistant.noConversationsYet')}</p>
|
||||
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">
|
||||
Ask Owliver something and it will be here afterwards. History is kept on this device.
|
||||
{t('krowAssistant.askOwliverSomethingAndItWill')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -178,6 +181,7 @@ export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean
|
||||
* so a narrow panel on a small screen cannot push the composer off the bottom.
|
||||
*/
|
||||
function SuggestedQuestions({ prompts, open, onSelect }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -189,7 +193,7 @@ function SuggestedQuestions({ prompts, open, onSelect }) {
|
||||
aria-hidden={open ? undefined : 'true'}
|
||||
>
|
||||
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
Suggested questions
|
||||
{t('krowAssistant.suggestedQuestions')}
|
||||
</p>
|
||||
{/* The existing chip component, and the existing submit path behind it —
|
||||
`runPrompt` is the same handler a typed question goes through. */}
|
||||
@@ -403,6 +407,14 @@ export default function KrowAssistant({
|
||||
*/
|
||||
const [view, setView] = React.useState('chat');
|
||||
|
||||
/* The account's language, chosen in the account menu rather than here. The
|
||||
panel reads it and never sets it: it is an application setting that happens
|
||||
to govern the one piece of text this product generates, not a property of
|
||||
the conversation. It applies from the next answer onward — answers already
|
||||
on screen stay as they were written. */
|
||||
const language = useLanguage();
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* A skill asked for something to happen. The names come from the skill file;
|
||||
* `runAction` decides what they mean and refuses anything the file did not
|
||||
@@ -637,6 +649,7 @@ export default function KrowAssistant({
|
||||
agentCoversPage,
|
||||
agentSuggestion: defaultAgent,
|
||||
owliverContext,
|
||||
language,
|
||||
});
|
||||
|
||||
/* A block inside an answer asking the next question, in place. Same entry
|
||||
@@ -679,9 +692,12 @@ export default function KrowAssistant({
|
||||
|
||||
/* Greeting and suggestions come from live data, so they recompute only when
|
||||
the data or the page actually changes. */
|
||||
/* `t` is a dependency, not just an argument: react-i18next returns a new one
|
||||
when the language changes, so the greeting recomputes on a switch rather
|
||||
than staying in whichever language the panel first opened in. */
|
||||
const intro = React.useMemo(
|
||||
() => buildIntro(context.id, facts, userName),
|
||||
[context.id, facts, userName]
|
||||
() => buildIntro(context.id, facts, userName, t),
|
||||
[context.id, facts, userName, t]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -908,7 +924,7 @@ export default function KrowAssistant({
|
||||
data-wide={expanded || undefined}
|
||||
className={cn('relative flex flex-col overflow-hidden', className)}
|
||||
role="region"
|
||||
aria-label="Owliver assistant"
|
||||
aria-label={t('assistant.assistant')}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
|
||||
@@ -931,7 +947,7 @@ export default function KrowAssistant({
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* The way back to a clean panel, positioned in front of History */}
|
||||
{canGoHome && (
|
||||
<IconButton icon={Home} label="Back to home" variant="ghost" size="sm" onClick={goHome} />
|
||||
<IconButton icon={Home} label={t('assistant.backToHome')} variant="ghost" size="sm" onClick={goHome} />
|
||||
)}
|
||||
{/* History lives with the other window controls rather than in the
|
||||
body, so the layout of the panel is unchanged whether or not
|
||||
@@ -947,13 +963,13 @@ export default function KrowAssistant({
|
||||
/>
|
||||
{expanded
|
||||
? onRestore && (
|
||||
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
|
||||
<IconButton icon={Minimize2} label={t('assistant.restoreWidth')} variant="ghost" size="sm" onClick={onRestore} />
|
||||
)
|
||||
: onExpand && (
|
||||
<IconButton icon={Maximize2} label="Expand the Owliver workspace" variant="ghost" size="sm" onClick={onExpand} />
|
||||
<IconButton icon={Maximize2} label={t('assistant.expandWorkspace')} variant="ghost" size="sm" onClick={onExpand} />
|
||||
)}
|
||||
{onClose && (
|
||||
<IconButton icon={PanelRightClose} label="Collapse the Owliver workspace" variant="ghost" size="sm" onClick={onClose} />
|
||||
<IconButton icon={PanelRightClose} label={t('assistant.collapseWorkspace')} variant="ghost" size="sm" onClick={onClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -989,7 +1005,7 @@ export default function KrowAssistant({
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearTest}
|
||||
aria-label="Dismiss test indicator"
|
||||
aria-label={t('assistant.dismissTestIndicator')}
|
||||
className={`shrink-0 rounded p-0.5 text-ink-4 transition-colors hover:text-ink-2
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* PromptChips — suggested prompts.
|
||||
@@ -30,6 +31,7 @@ export function PromptChips({
|
||||
something invisible is a keyboard user losing their place. */
|
||||
focusable = true,
|
||||
}: any) {
|
||||
const { t } = useTranslation();
|
||||
const chipRefs = React.useRef([]);
|
||||
const visible = max ? prompts.slice(0, max) : prompts;
|
||||
|
||||
@@ -47,7 +49,7 @@ export function PromptChips({
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Suggested prompts"
|
||||
aria-label={t('assistant.suggestedPrompts')}
|
||||
className={cn(
|
||||
'flex flex-wrap gap-1.5',
|
||||
align === 'center' ? 'justify-center' : 'justify-start',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowUp, Square } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* PromptInput — the assistant's primary action.
|
||||
@@ -28,6 +29,7 @@ export function PromptInput({
|
||||
align = 'left',
|
||||
className = '',
|
||||
}: any) {
|
||||
const { t } = useTranslation();
|
||||
const textareaRef = React.useRef(null);
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
@@ -79,7 +81,7 @@ export function PromptInput({
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
disabled={busy}
|
||||
aria-label="Ask Owliver"
|
||||
aria-label={t('assistant.ask')}
|
||||
aria-busy={busy || undefined}
|
||||
className={cn(
|
||||
'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
|
||||
@@ -101,7 +103,7 @@ export function PromptInput({
|
||||
isCenter ? 'text-center' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{busy ? 'Thinking…' : placeholders[0]}
|
||||
{busy ? t('assistant.thinking') : placeholders[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -110,7 +112,7 @@ export function PromptInput({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
aria-label="Stop generating"
|
||||
aria-label={t('assistant.stopGenerating')}
|
||||
className={cn(
|
||||
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
|
||||
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
|
||||
@@ -123,7 +125,7 @@ export function PromptInput({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim()}
|
||||
aria-label="Send message"
|
||||
aria-label={t('assistant.sendMessage')}
|
||||
className={cn(
|
||||
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
|
||||
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* ResizeDivider — the drag handle between the dashboard and the Owliver panel.
|
||||
@@ -15,6 +16,7 @@ import { cn } from '@/lib/utils';
|
||||
* limits, and the whole thing is exposed as a `separator` with live values.
|
||||
*/
|
||||
export function ResizeDivider({ width, min, max, onResize, onDoubleClick, className }) {
|
||||
const { t } = useTranslation();
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
|
||||
/* Pointer events on the window rather than the element: once a drag starts the
|
||||
@@ -66,7 +68,7 @@ export function ResizeDivider({ width, min, max, onResize, onDoubleClick, classN
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize Owliver panel"
|
||||
aria-label={t('assistant.resizePanel')}
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
|
||||
@@ -814,8 +814,19 @@ const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] ||
|
||||
* Everything the landing screen needs, computed from current data.
|
||||
* `userName` personalizes the greeting where a name is known.
|
||||
*/
|
||||
export function buildIntro(contextId, facts, userName) {
|
||||
export function buildIntro(contextId, facts, userName, t = null) {
|
||||
const name = firstName(userName);
|
||||
|
||||
/* `t` is optional so a caller that has not been converted yet still gets the
|
||||
English it got before. Every map below keeps its English function; the
|
||||
translated path is taken only where a key exists, which is what lets this
|
||||
file be converted a context at a time instead of in one unreviewable
|
||||
change. */
|
||||
if (t) {
|
||||
const translated = translatedIntro(contextId, facts, name, t);
|
||||
if (translated) return translated;
|
||||
}
|
||||
|
||||
return {
|
||||
title: TITLES[contextId]?.(facts, name) ?? 'Owliver',
|
||||
greeting: GREETINGS[contextId]?.(facts) ?? '',
|
||||
@@ -824,6 +835,54 @@ export function buildIntro(contextId, facts, userName) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The translated intro for a context, or null where there is not one yet.
|
||||
*
|
||||
* The greeting is NOT the same sentence with the words swapped. "Good morning"
|
||||
* is one key per time of day rather than "Good " plus a translated noun,
|
||||
* because Spanish says "Buenos días" and "Buenas tardes" — the adjective agrees
|
||||
* with the noun, so a sentence assembled from parts is wrong in one of them.
|
||||
* The same reasoning is why counts go through i18next's own plural forms rather
|
||||
* than through `plural()`, which appends an English "s".
|
||||
*/
|
||||
function translatedIntro(contextId, facts, name, t) {
|
||||
const part = timeOfDay(facts.today);
|
||||
const title = name
|
||||
? t(`assistant.greeting.${part}Named`, { name })
|
||||
: t(`assistant.greeting.${part}`);
|
||||
|
||||
if (contextId === 'admin.controlCenter') {
|
||||
/* The greeting picks ONE line by urgency, the same order the English map
|
||||
uses — something unscored beats something merely recent, which beats
|
||||
"steady". Rebuilt here rather than translated after the fact, because the
|
||||
English version has already chosen its words and its plural by the time
|
||||
it returns a string. */
|
||||
const greeting = facts.unscoredProfiles?.length
|
||||
? t('ccAssistant.unscored', { count: facts.unscoredProfiles.length })
|
||||
: facts.activity24h?.length
|
||||
? t('ccAssistant.events', { count: facts.activity24h.length })
|
||||
: t('ccAssistant.steady', {
|
||||
positions: t('assistant.controlCenter.positions', { count: facts.postings.length }),
|
||||
applications: t('assistant.controlCenter.applications', { count: facts.total }),
|
||||
});
|
||||
|
||||
return {
|
||||
title,
|
||||
greeting,
|
||||
description: t('assistant.controlCenter.description', {
|
||||
applications: t('assistant.controlCenter.applications', { count: facts.total }),
|
||||
positions: t('assistant.controlCenter.positions', { count: facts.postings.length }),
|
||||
}),
|
||||
placeholders: [t('ccAssistant.placeholder')],
|
||||
};
|
||||
}
|
||||
|
||||
/* Every other context still answers in English, deliberately: a half
|
||||
translated intro that silently falls back is better than one assembled
|
||||
from a key that does not exist. */
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Suggested prompts for the current page and data. */
|
||||
export function buildPrompts(contextId, facts, workforce = null) {
|
||||
return PROMPTS[contextId]?.(facts, workforce) ?? [];
|
||||
|
||||
@@ -95,6 +95,14 @@ export function buildFacts({
|
||||
const transitions = funnel.slice(1).map((stage, i) => ({
|
||||
from: funnel[i].label,
|
||||
to: stage.label,
|
||||
/* The same two stages by KEY. `from`/`to` are English labels and several
|
||||
callers render them directly; the keys are what a translated surface
|
||||
needs, because a label cannot be looked up in a catalogue — asking for
|
||||
`stage.Interviewed` returns that string verbatim, which is how a raw key
|
||||
reached the screen. Both are carried rather than one replaced, so no
|
||||
existing reader changes behaviour. */
|
||||
fromKey: funnel[i].key,
|
||||
toKey: stage.key,
|
||||
rate: pct(stage.count, funnel[i].count),
|
||||
lost: Math.max(0, funnel[i].count - stage.count),
|
||||
}));
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
* capability: string | null, // capability id, or null for free text
|
||||
* question: string, // what the user typed, or the chip's prompt
|
||||
* facts: object, // dashboard fact sheet (see insights.js)
|
||||
* language: string | null, // language tag to answer in (see language.js)
|
||||
* signal: AbortSignal, // aborts an in-flight response
|
||||
* }
|
||||
*/
|
||||
@@ -68,7 +69,7 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||
},
|
||||
|
||||
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
|
||||
async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
||||
async *run({ question, agent = null, confirmation = null, agentVersion = 0, language = null, signal }) {
|
||||
/* No agent, no run. The panel resolves which agent covers the page before
|
||||
calling; reaching here without one means the routing layer changed and
|
||||
this should say so rather than guess at an agent id. */
|
||||
@@ -98,6 +99,15 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||
stops an edit published mid-thread from silently changing which
|
||||
agent is answering. */
|
||||
agentVersion: agentVersion || undefined,
|
||||
/* The language to answer in. Omitted when it is the default, so an
|
||||
English run sends exactly the body it sent before the selector
|
||||
existed — and a deployment that does not know the field yet is
|
||||
unaffected by the common case.
|
||||
|
||||
A tag, never a sentence. The server maps it onto a closed set and
|
||||
an unrecognised one answers in English; the panel cannot write
|
||||
prompt text from here, which is the point of sending a tag. */
|
||||
language: language && language !== 'en' ? language : undefined,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { agentRequest } from '@/lib/agents/runtime';
|
||||
import { createAssistantProvider } from './provider';
|
||||
import { preferAgent, resolveIntent } from './routing';
|
||||
import { doc, text as textBlock, toSnapshots } from './blocks';
|
||||
import { DEFAULT_LANGUAGE } from '@/lib/i18n/language';
|
||||
|
||||
/** One provider instance for the app's lifetime. */
|
||||
const provider = createAssistantProvider();
|
||||
@@ -199,14 +200,26 @@ const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/
|
||||
*
|
||||
* Two rules, and the second is the one that matters. The suggestions are ranked
|
||||
* by the SERVER against the question just asked — the panel does not decide what
|
||||
* is worth asking, it only decides what has already been said — and then
|
||||
* anything this thread has asked or already offered is removed.
|
||||
* is worth asking, it only decides what has already been said — and then what
|
||||
* this thread has already covered is removed.
|
||||
*
|
||||
* Without that second rule the row repeats. A page carries a handful of intents
|
||||
* and the top of that list barely moves between turns, so the same three chips
|
||||
* come back after every answer, including the one the reader has just pressed.
|
||||
* Removing what has been used leaves genuinely new ground each time and runs out
|
||||
* honestly rather than looping.
|
||||
*
|
||||
* ASKED AND OFFERED ARE NOT THE SAME THING, and treating them as one is what
|
||||
* made the row appear exactly once per conversation. Every chip ever *shown*
|
||||
* was being banned permanently, in a set that only grew; since the server keeps
|
||||
* returning the top of the same small catalogue, the second turn's suggestions
|
||||
* were all already in it, the filter emptied the list, and an empty list means
|
||||
* no row. Every turn after the first was silent, whatever was asked.
|
||||
*
|
||||
* So the two are separated. A question this thread actually PUT is excluded for
|
||||
* good — it has an answer on screen, and offering to repeat it is not a follow-
|
||||
* up. A chip merely DISPLAYED and passed over is held back only from the turn
|
||||
* directly after it, which is enough to stop the row redrawing verbatim under
|
||||
* consecutive answers; beyond that it is offerable again, because a reader
|
||||
* ignoring a suggestion is not the same as having covered it.
|
||||
*
|
||||
* There is NO fallback to the page's own ranking, and that is the correction a
|
||||
* live run forced. Asking "Summarize hiring activity" matches nothing in the
|
||||
@@ -220,18 +233,34 @@ const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/
|
||||
export async function nextSteps({ question, history, refresh }) {
|
||||
if (!refresh) return undefined;
|
||||
|
||||
const used = new Set([asQuestion(question)]);
|
||||
/* Permanent: what this thread has actually asked, including the question
|
||||
being answered right now. */
|
||||
const asked = new Set([asQuestion(question)]);
|
||||
for (const message of history) {
|
||||
if (message.role === 'user') used.add(asQuestion(message.text));
|
||||
for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
|
||||
if (message.role === 'user') asked.add(asQuestion(message.text));
|
||||
}
|
||||
|
||||
/* Transient: the row under the answer directly above this one, and only
|
||||
that row. The immediately preceding assistant turn, whether or not it
|
||||
carried chips — if it offered none there is nothing to hold back, and a
|
||||
suggestion last seen further up the thread has had an answer between it
|
||||
and now, so repeating it is not the verbatim redraw this guards against. */
|
||||
const justOffered = new Set();
|
||||
for (let i = history.length - 1; i >= 0; i -= 1) {
|
||||
if (history[i]?.role !== 'assistant') continue;
|
||||
for (const chip of history[i].followUp || []) {
|
||||
justOffered.add(asQuestion(chip.prompt || chip.label));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const unused = (chips) => (chips || []).filter((chip) => {
|
||||
const key = asQuestion(chip.prompt || chip.label);
|
||||
if (!key || used.has(key)) return false;
|
||||
if (!key || asked.has(key) || justOffered.has(key) || seen.has(key)) return false;
|
||||
/* A list that repeats itself within one turn is the same defect at a
|
||||
smaller scale. */
|
||||
used.add(key);
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -336,6 +365,11 @@ export function useConversation({
|
||||
/* The page's own name, recorded with an archived thread so History can say
|
||||
where a conversation happened without resolving the context again. */
|
||||
pageLabel = '',
|
||||
/* The language the agent answers in — a tag from `language.js`, owned by the
|
||||
header's selector. Read here only to put it on the request: nothing in this
|
||||
hook branches on it, because translating is the model's job and not the
|
||||
panel's. */
|
||||
language = DEFAULT_LANGUAGE,
|
||||
}) {
|
||||
/**
|
||||
* One thread per agent per page.
|
||||
@@ -888,6 +922,9 @@ export function useConversation({
|
||||
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
||||
owliverContext,
|
||||
agentVersion: pinnedVersionRef.current,
|
||||
/* The reader's choice, applied from the next answer onward. Answers
|
||||
already on screen are left as they were written. */
|
||||
language,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
latest = snapshot;
|
||||
@@ -968,7 +1005,7 @@ export function useConversation({
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext, language,
|
||||
/* The layout session changes as a page's composition and the account's
|
||||
skills resolve, and a stale one means the tree Owliver inspects is the
|
||||
empty one from the first render — so a layout request falls through to
|
||||
@@ -1018,6 +1055,9 @@ export function useConversation({
|
||||
owliverContext,
|
||||
confirmation: block.token,
|
||||
agentVersion: pinnedVersionRef.current,
|
||||
/* The reader's choice, applied from the next answer onward. Answers
|
||||
already on screen are left as they were written. */
|
||||
language,
|
||||
signal: controller.signal,
|
||||
})) {
|
||||
if (controller.signal.aborted) break;
|
||||
@@ -1037,7 +1077,7 @@ export function useConversation({
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, agent, owliverContext, persist]);
|
||||
}, [contextId, facts, agent, owliverContext, language, persist]);
|
||||
|
||||
/**
|
||||
* States something in the thread without a question having been asked.
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Animated Flowchart & Department Performance Component
|
||||
@@ -106,6 +107,7 @@ function getDeptColor(index, avgScore) {
|
||||
|
||||
/** @param {any} props */
|
||||
export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
||||
const [activeDept, setActiveDept] = useState(null);
|
||||
|
||||
@@ -135,10 +137,10 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-heading text-body font-semibold text-ink-1">
|
||||
Department Performance & Hiring Flow
|
||||
{t('departmentPerformance.departmentPerformanceHiringFlow')}
|
||||
</h3>
|
||||
<p className="text-caption text-ink-3">
|
||||
Interactive pipeline mapping AI score quality & hire velocity across departments
|
||||
{t('departmentPerformance.interactivePipelineMappingAiScoreQuality')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +158,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
)}
|
||||
>
|
||||
<Workflow className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>Flowchart</span>
|
||||
<span>{t('departmentPerformance.flowchart')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -169,7 +171,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
)}
|
||||
>
|
||||
<TableIcon className="h-3.5 w-3.5 text-ink-3" />
|
||||
<span>Scorecard</span>
|
||||
<span>{t('departmentPerformance.scorecard')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +183,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Top Department</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">{t('departmentPerformance.topDepartment')}</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{ranked[0]?.name || 'Chef'} <span className="text-blue-600 font-semibold">({ranked[0]?.avgScore || 97})</span>
|
||||
</p>
|
||||
@@ -193,9 +195,9 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
<Building2 className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Active Units</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">{t('departmentPerformance.activeUnits')}</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{ranked.length} Departments
|
||||
{ranked.length} {t('departmentPerformance.departments')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,7 +207,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Overall AI Index</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">{t('departmentPerformance.overallAiIndex')}</p>
|
||||
<p className="font-heading text-body-sm font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{overallAvgScore} / 100
|
||||
</p>
|
||||
@@ -217,9 +219,9 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
<Clock className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Top Fill Velocity</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">{t('departmentPerformance.topFillVelocity')}</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{Math.min(...ranked.map((r) => r.avgTimeToHire || 2))}d Avg
|
||||
{Math.min(...ranked.map((r) => r.avgTimeToHire || 2))}{t('departmentPerformance.dAvg')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,29 +257,29 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-600 animate-pulse" />
|
||||
Pipeline Source
|
||||
{t('departmentPerformance.pipelineSource')}
|
||||
</span>
|
||||
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
||||
Talent Engine
|
||||
{t('departmentPerformance.talentEngine')}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="rounded-full bg-blue-100 dark:bg-blue-950 px-2.5 py-1 text-[10px] font-bold text-blue-700 dark:text-blue-400">
|
||||
100% AI Verified Engine
|
||||
{t('departmentPerformance.100AiVerifiedEngine')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 border-t border-border/60 pt-3 text-caption">
|
||||
<div className="rounded-xl bg-surface-subtle p-2.5">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Total Hires</p>
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">{t('departmentPerformance.totalHires')}</p>
|
||||
<p className="font-heading text-body-lg font-bold tabular-nums text-ink-1 mt-0.5">
|
||||
{totalHires} <span className="text-caption font-normal text-ink-3">staff filled</span>
|
||||
{totalHires} <span className="text-caption font-normal text-ink-3">{t('departmentPerformance.staffFilled')}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2.5">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Avg AI Score</p>
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">{t('departmentPerformance.avgAiScore')}</p>
|
||||
<p className="font-heading text-body-lg font-bold tabular-nums text-blue-600 dark:text-blue-400 mt-0.5">
|
||||
{overallAvgScore} <span className="text-caption font-normal text-ink-3">/ 100</span>
|
||||
</p>
|
||||
@@ -297,35 +299,35 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
|
||||
Hiring Outcomes
|
||||
{t('departmentPerformance.hiringOutcomes')}
|
||||
</span>
|
||||
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
||||
Verified Quality Impact
|
||||
{t('departmentPerformance.verifiedQualityImpact')}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="rounded-full bg-emerald-100 dark:bg-emerald-950 px-2.5 py-1 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
|
||||
100% Active Placement
|
||||
{t('departmentPerformance.100ActivePlacement')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 border-t border-border/60 pt-3 text-caption">
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Quality Tier</p>
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">{t('departmentPerformance.qualityTier')}</p>
|
||||
<p className="font-semibold text-emerald-600 dark:text-emerald-400 mt-0.5 text-body-sm">
|
||||
95+ Top
|
||||
{t('departmentPerformance.95Top')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Placement</p>
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">{t('departmentPerformance.placement')}</p>
|
||||
<p className="font-semibold text-ink-1 mt-0.5 text-body-sm">
|
||||
100% Verified
|
||||
{t('departmentPerformance.100Verified')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Avg Rating</p>
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">{t('departmentPerformance.avgRating')}</p>
|
||||
<p className="font-semibold text-amber-600 dark:text-amber-400 mt-0.5 text-body-sm">
|
||||
4.9 / 5.0
|
||||
</p>
|
||||
@@ -338,9 +340,9 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-heading text-body-sm font-bold text-ink-1 uppercase tracking-wider text-[11px]">
|
||||
Department Performance Units ({ranked.length})
|
||||
{t('departmentPerformance.departmentPerformanceUnits')}{ranked.length})
|
||||
</h4>
|
||||
<span className="text-[10px] text-ink-4">Click any department card for detailed staff roster</span>
|
||||
<span className="text-[10px] text-ink-4">{t('departmentPerformance.clickAnyDepartmentCardForDetailed')}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
@@ -397,7 +399,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
<Clock className="h-3 w-3" />
|
||||
{dept.avgTimeToHire}d fill velocity
|
||||
{dept.avgTimeToHire}{t('departmentPerformance.dFillVelocity')}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
@@ -460,11 +462,11 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
<table className="w-full min-w-[40rem] text-left text-body-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4">
|
||||
<th className="px-4 py-3">Rank</th>
|
||||
<th className="px-4 py-3">Department</th>
|
||||
<th className="px-4 py-3">Roles Included</th>
|
||||
<th className="px-4 py-3 text-right">Volume</th>
|
||||
<th className="px-4 py-3 text-right">Avg AI Score</th>
|
||||
<th className="px-4 py-3">{t('departmentPerformance.rank')}</th>
|
||||
<th className="px-4 py-3">{t('departmentPerformance.department')}</th>
|
||||
<th className="px-4 py-3">{t('departmentPerformance.rolesIncluded')}</th>
|
||||
<th className="px-4 py-3 text-right">{t('departmentPerformance.volume')}</th>
|
||||
<th className="px-4 py-3 text-right">{t('departmentPerformance.avgAiScore')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
@@ -535,9 +537,9 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-heading text-title font-bold text-ink-1">
|
||||
{activeDept.name} Department
|
||||
{activeDept.name} {t('departmentPerformance.department')}
|
||||
</h4>
|
||||
<p className="text-caption text-ink-3">Hiring pipeline & outcome analytics</p>
|
||||
<p className="text-caption text-ink-3">{t('departmentPerformance.hiringPipelineOutcomeAnalytics')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -552,19 +554,19 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
{/* Quick Key Specs */}
|
||||
<div className="grid grid-cols-3 gap-3 rounded-xl bg-surface-subtle p-3 text-center border border-border/50">
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Avg AI Score</p>
|
||||
<p className="text-caption text-ink-4">{t('departmentPerformance.avgAiScore')}</p>
|
||||
<p className="font-heading text-title font-bold text-blue-600 dark:text-blue-400">
|
||||
{activeDept.avgScore || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Total Hires</p>
|
||||
<p className="text-caption text-ink-4">{t('departmentPerformance.totalHires')}</p>
|
||||
<p className="font-heading text-title font-bold text-ink-1">
|
||||
{activeDept.count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Avg Velocity</p>
|
||||
<p className="text-caption text-ink-4">{t('departmentPerformance.avgVelocity')}</p>
|
||||
<p className="font-heading text-title font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{activeDept.avgTimeToHire ? `${activeDept.avgTimeToHire}d` : '3d'}
|
||||
</p>
|
||||
@@ -575,7 +577,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
{activeDept.hiresList && activeDept.hiresList.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-caption font-semibold uppercase tracking-wider text-ink-4">
|
||||
Hired Staff Members
|
||||
{t('departmentPerformance.hiredStaffMembers')}
|
||||
</h5>
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
{activeDept.hiresList.map((h, i) => (
|
||||
@@ -607,7 +609,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||
onClick={() => setActiveDept(null)}
|
||||
className="rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Close Inspection
|
||||
{t('departmentPerformance.closeInspection')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from 'react';
|
||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const alertVariants = cva('flex items-start gap-3 rounded-xl border p-4 animate-slide-down', {
|
||||
variants: {
|
||||
@@ -42,6 +43,7 @@ const ICON_COLORS = {
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }: DsProps) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = icon || ICONS[tone];
|
||||
|
||||
return (
|
||||
@@ -62,7 +64,7 @@ export function Alert({ tone = 'info', title, children, icon, onDismiss, action,
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
aria-label={t('alert.dismiss')}
|
||||
className="grid place-items-center w-6 h-6 rounded-md text-ink-4 hover:text-ink-1 hover:bg-black/5 transition-colors shrink-0 focus-ring-inset"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SectionHeader } from '@/components/ds/SectionHeader';
|
||||
import { EmptyState } from '@/components/ds/EmptyState';
|
||||
import { SkeletonChart } from '@/components/ds/Skeleton';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The chart palette. Recharts needs literal colours, not Tailwind classes, so
|
||||
@@ -120,6 +121,7 @@ export function ChartContainer({
|
||||
footer,
|
||||
className,
|
||||
}: DsProps) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) return <SkeletonChart className={className} height={height} />;
|
||||
|
||||
return (
|
||||
@@ -159,7 +161,7 @@ export function ChartContainer({
|
||||
|
||||
<div className={cn('flex-1 min-w-0', !legend && 'mt-4')} style={{ minHeight: height }}>
|
||||
{isEmpty ? (
|
||||
<EmptyState icon={BarChart3} variant="filtered" title="Nothing to chart" description={emptyMessage} size="sm" />
|
||||
<EmptyState icon={BarChart3} variant="filtered" title={t('chartContainer.nothingToChart')} description={emptyMessage} size="sm" />
|
||||
) : raw ? (
|
||||
children
|
||||
) : (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { LoadingOverlay } from '@/components/ds/LoadingState';
|
||||
import { Pagination } from '@/components/ds/Pagination';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* DataTable — the app's one table.
|
||||
@@ -62,6 +63,7 @@ export function DataTable({
|
||||
rowClassName,
|
||||
stickyHeader = false,
|
||||
}: DsProps) {
|
||||
const { t } = useTranslation();
|
||||
/* ── Sorting ─────────────────────────────────────────────────────────── */
|
||||
/* Row height follows the account's density preference. Only the vertical
|
||||
padding changes — same columns, same type, same behaviour. */
|
||||
@@ -205,7 +207,7 @@ export function DataTable({
|
||||
<Checkbox
|
||||
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all rows on this page"
|
||||
aria-label={t('dataTable.selectAllRowsOnThisPage')}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { SearchInput } from '@/components/ds/SearchInput';
|
||||
import { MultiSelect } from '@/components/ds/MultiSelect';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* FilterBar — the search + filters row above a list, table or grid.
|
||||
@@ -39,6 +40,7 @@ export function FilterBar({
|
||||
allValue = 'all',
|
||||
className = '',
|
||||
}: DsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const isActive = (filter) => {
|
||||
@@ -96,7 +98,7 @@ export function FilterBar({
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<SlidersHorizontal aria-hidden="true" />
|
||||
Filters
|
||||
{t('filterBar.filters')}
|
||||
</span>
|
||||
{activeFilters.length > 0 && (
|
||||
<Badge variant="default" size="sm">{activeFilters.length}</Badge>
|
||||
@@ -181,7 +183,7 @@ export function FilterBar({
|
||||
onClick={clearAll}
|
||||
className="text-caption font-medium text-ink-3 hover:text-ink-1 hover:underline transition-colors focus-ring rounded-sm"
|
||||
>
|
||||
Clear all
|
||||
{t('filterBar.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SearchInput } from '@/components/ds/SearchInput';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* MultiSelect — pick several values from a list.
|
||||
@@ -27,6 +28,7 @@ export function MultiSelect({
|
||||
align = 'start',
|
||||
emptyMessage = 'No options',
|
||||
}: DsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
|
||||
@@ -112,7 +114,7 @@ export function MultiSelect({
|
||||
<SearchInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Filter options"
|
||||
placeholder={t('multiSelect.filterOptions')}
|
||||
size="sm"
|
||||
debounce={0}
|
||||
autoFocus
|
||||
@@ -163,7 +165,7 @@ export function MultiSelect({
|
||||
onClick={() => onChange?.([])}
|
||||
className="w-full rounded-md px-2 py-1.5 text-body-sm text-ink-3 hover:bg-surface-sunken hover:text-ink-1 transition-colors focus-ring-inset"
|
||||
>
|
||||
Clear selection
|
||||
{t('multiSelect.clearSelection')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Builds a compact page list with ellipses: 1 … 4 5 6 … 20.
|
||||
@@ -52,6 +53,7 @@ export function Pagination({
|
||||
pageSizeOptions = [10, 25, 50, 100],
|
||||
className,
|
||||
}: DsProps) {
|
||||
const { t } = useTranslation();
|
||||
if (pageCount <= 1 && !onPageSizeChange) return null;
|
||||
|
||||
const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null;
|
||||
@@ -60,20 +62,20 @@ export function Pagination({
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Pagination"
|
||||
aria-label={t('pagination.pagination')}
|
||||
className={cn('flex flex-wrap items-center justify-between gap-3', className)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{from != null && (
|
||||
<p className="text-caption text-ink-3 tabular-nums">
|
||||
Showing <span className="font-medium text-ink-2">{from}–{to}</span> of{' '}
|
||||
{t('pagination.showing')} <span className="font-medium text-ink-2">{from}–{to}</span> of{' '}
|
||||
<span className="font-medium text-ink-2">{totalItems}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{onPageSizeChange && (
|
||||
<label className="flex items-center gap-1.5 text-caption text-ink-3">
|
||||
<span className="hidden sm:inline">Rows</span>
|
||||
<span className="hidden sm:inline">{t('pagination.rows')}</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
@@ -93,7 +95,7 @@ export function Pagination({
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
shape="rounded"
|
||||
aria-label="Previous page"
|
||||
aria-label={t('pagination.previousPage')}
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange?.(page - 1)}
|
||||
>
|
||||
@@ -125,7 +127,7 @@ export function Pagination({
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
shape="rounded"
|
||||
aria-label="Next page"
|
||||
aria-label={t('pagination.nextPage')}
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => onPageChange?.(page + 1)}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const clamp = (n) => Math.max(0, Math.min(100, Number(n) || 0));
|
||||
|
||||
@@ -245,12 +246,13 @@ export function ProgressSteps({ steps = [], current = 0, className = '', onStepC
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function ScoreMeter({ label, value, weight, tone = 'score', className }: DsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm text-ink-2 truncate">{label}</span>
|
||||
<span className="flex items-baseline gap-1.5 shrink-0">
|
||||
{weight != null && <span className="text-[10px] text-ink-4">{weight}% weight</span>}
|
||||
{weight != null && <span className="text-[10px] text-ink-4">{weight}{t('progress.weight')}</span>}
|
||||
<span className="text-body-sm font-semibold text-ink-1 tabular-nums">{value}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Skeleton — a loading placeholder.
|
||||
@@ -35,8 +36,9 @@ export function Skeleton({ className = '', radius = 'md', ...props }: DsProps) {
|
||||
/** Paragraph placeholder. The last line is short, as real text is. */
|
||||
/** @param {any} props */
|
||||
export function SkeletonText({ lines = 3, className = '' }: DsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
|
||||
<div className={cn('space-y-2', className)} role="status" aria-label={t('skeleton.loadingContent')}>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
@@ -102,8 +104,9 @@ export function SkeletonCard({ className = '' }: DsProps) {
|
||||
/** Matches DataTable: header row plus n body rows. */
|
||||
/** @param {any} props */
|
||||
export function SkeletonTable({ rows = 5, columns = 4, className = '' }: DsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn('overflow-hidden', className)} role="status" aria-label="Loading table">
|
||||
<div className={cn('overflow-hidden', className)} role="status" aria-label={t('skeleton.loadingTable')}>
|
||||
<div className="flex items-center gap-4 px-4 py-3 border-b border-border bg-surface-subtle">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<Skeleton key={i} className={cn('h-2.5', i === 0 ? 'w-32' : 'flex-1 max-w-24')} />
|
||||
@@ -139,8 +142,9 @@ export function SkeletonChart({ className = '', height = 240 }: DsProps) {
|
||||
/** n SkeletonRows inside a surface — the common "loading a list" case. */
|
||||
/** @param {any} props */
|
||||
export function SkeletonList({ rows = 5, className = '' }: DsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
|
||||
<div className={cn('divide-y divide-border', className)} role="status" aria-label={t('skeleton.loadingList')}>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<SkeletonRow key={i} />
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DsProps } from './props';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The domain's states, mapped to a tone and a human label in one place.
|
||||
@@ -80,9 +81,22 @@ const DOT_COLOR = {
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }: DsProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const entry = STATUS_MAP[status] || {};
|
||||
const resolvedTone = tone || entry.tone || 'neutral';
|
||||
const resolvedLabel = label || entry.label || humanize(status);
|
||||
|
||||
/* STATUS_MAP stays the single list of statuses and their tones; the catalogue
|
||||
holds the words. Translated by STATUS rather than by the English label, so
|
||||
"Hired" the application state and "Hired" the badge on a worker can diverge
|
||||
later without one of them silently changing the other.
|
||||
An explicit `label` prop still wins — a caller passing its own wording has
|
||||
already decided, and `i18n.exists` keeps an unknown status on the
|
||||
humanised fallback instead of inventing a key for it. */
|
||||
const key = `status.${status}`;
|
||||
const resolvedLabel = label
|
||||
|| (status && i18n.exists(key) ? t(key) : null)
|
||||
|| entry.label
|
||||
|| humanize(status);
|
||||
|
||||
return (
|
||||
<Badge variant={resolvedTone} size={size} className={cn('gap-1.5', className)} {...props}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, Plus, Sparkles, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, Field, Input, Modal, Textarea, toast } from '@/components/ds';
|
||||
import { TYPE_HINT, TYPE_LABEL } from './challengeMeta';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Authoring a workforce skill, in the order the concept runs:
|
||||
@@ -64,6 +65,7 @@ interface AddSkillTrainingProps {
|
||||
export function AddSkillTraining({
|
||||
open, onOpenChange, categories = [], onCreate, onUpdate, busy, prefill, startStep, courseId,
|
||||
}: AddSkillTrainingProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = React.useState(0);
|
||||
const [form, setForm] = React.useState(EMPTY);
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
@@ -184,7 +186,7 @@ export function AddSkillTraining({
|
||||
{isLast ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => submit('draft')} disabled={busy}>
|
||||
Save draft
|
||||
{t('addSkillTraining.saveDraft')}
|
||||
</Button>
|
||||
{/* Publishing puts a skill in front of the workforce, so it asks
|
||||
first rather than firing on the same click that reveals it. */}
|
||||
@@ -201,7 +203,7 @@ export function AddSkillTraining({
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => setStep(step + 1)} disabled={!canAdvance}>
|
||||
Next
|
||||
{t('addSkillTraining.next')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -211,20 +213,20 @@ export function AddSkillTraining({
|
||||
|
||||
{step === 0 && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Skill name" required hint="What the workforce must be able to do.">
|
||||
<Field label={t('addSkillTraining.skillName')} required hint={t('addSkillTraining.whatTheWorkforceMustBeAble')}>
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => set({ title: e.target.value })}
|
||||
placeholder="Knife Skills — Basic Cuts"
|
||||
placeholder={t('addSkillTraining.knifeSkillsBasicCuts')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Category" required hint={categories.length ? 'Reuse one, or name a new one.' : undefined}>
|
||||
<Field label={t('addSkillTraining.category')} required hint={categories.length ? 'Reuse one, or name a new one.' : undefined}>
|
||||
<Input
|
||||
value={form.category}
|
||||
onChange={(e) => set({ category: e.target.value })}
|
||||
placeholder="Kitchen"
|
||||
placeholder={t('addSkillTraining.kitchen')}
|
||||
list="forge-categories"
|
||||
/>
|
||||
</Field>
|
||||
@@ -232,8 +234,8 @@ export function AddSkillTraining({
|
||||
{categories.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
|
||||
<Field label="Difficulty">
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Difficulty">
|
||||
<Field label={t('addSkillTraining.difficulty')}>
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label={t('addSkillTraining.difficulty')}>
|
||||
{DIFFICULTIES.map((d) => (
|
||||
<Choice
|
||||
key={d}
|
||||
@@ -248,26 +250,26 @@ export function AddSkillTraining({
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description" required hint="One or two sentences a worker would recognise the job in.">
|
||||
<Field label={t('addSkillTraining.description')} required hint={t('addSkillTraining.oneOrTwoSentencesAWorker')}>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={(e) => set({ description: e.target.value })}
|
||||
placeholder="Learn safe and consistent basic knife techniques used in professional kitchens."
|
||||
placeholder={t('addSkillTraining.learnSafeAndConsistentBasicKnife')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Verified skill name" hint="The capability this writes onto a worker's profile. Defaults to the skill name.">
|
||||
<Field label={t('addSkillTraining.verifiedSkillName')} hint={t('addSkillTraining.theCapabilityThisWritesOntoA')}>
|
||||
<Input
|
||||
value={form.proof_skill}
|
||||
onChange={(e) => set({ proof_skill: e.target.value })}
|
||||
placeholder="Basic Knife Cuts"
|
||||
placeholder={t('addSkillTraining.basicKnifeCuts')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{missing.length > 0 && (
|
||||
<p className="text-caption text-ink-4">
|
||||
Still needs {missing.join(', ')}.
|
||||
{t('addSkillTraining.stillNeeds')} {missing.join(', ')}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -276,15 +278,15 @@ export function AddSkillTraining({
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<ListBuilder
|
||||
label="Training steps"
|
||||
hint="What the workforce is taught, in order."
|
||||
placeholder="Knife grip"
|
||||
label={t('addSkillTraining.trainingSteps')}
|
||||
hint={t('addSkillTraining.whatTheWorkforceIsTaughtIn')}
|
||||
placeholder={t('addSkillTraining.knifeGrip')}
|
||||
values={form.outline}
|
||||
onChange={(outline) => set({ outline })}
|
||||
addLabel="Add step"
|
||||
/>
|
||||
|
||||
<Field label="Estimated duration" hint="Minutes, as shown on the skill card.">
|
||||
<Field label={t('addSkillTraining.estimatedDuration')} hint={t('addSkillTraining.minutesAsShownOnTheSkill')}>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -295,16 +297,14 @@ export function AddSkillTraining({
|
||||
</Field>
|
||||
|
||||
<p className="rounded-lg border border-border bg-surface-subtle p-3 text-caption leading-relaxed text-ink-3">
|
||||
Training in Forge is written instruction plus the practice challenge on the next
|
||||
step. Video and attachments are not part of this deployment, so they are not
|
||||
offered here.
|
||||
{t('addSkillTraining.trainingInForgeIsWrittenInstruction')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Proof method" hint="How a worker demonstrates the skill. Each one is graded by Owliver.">
|
||||
<Field label={t('addSkillTraining.proofMethod')} hint={t('addSkillTraining.howAWorkerDemonstratesTheSkill')}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{PROOF_TYPES.map((t) => (
|
||||
<button
|
||||
@@ -326,14 +326,14 @@ export function AddSkillTraining({
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="The task"
|
||||
hint="What you are asking the worker to do. This is what they read when the challenge opens."
|
||||
label={t('addSkillTraining.theTask')}
|
||||
hint={t('addSkillTraining.whatYouAreAskingTheWorker')}
|
||||
>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.prompt}
|
||||
onChange={(e) => set({ prompt: e.target.value })}
|
||||
placeholder="Record yourself preparing a julienne, a dice and a chop from one onion."
|
||||
placeholder={t('addSkillTraining.recordYourselfPreparingAJulienneA')}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
@@ -344,24 +344,23 @@ export function AddSkillTraining({
|
||||
<div className="rounded-lg border border-krow-blue/30 bg-krow-blue-tint/40 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-krow-blue">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
Owliver evaluates
|
||||
{t('addSkillTraining.owliverEvaluates')}
|
||||
</p>
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-3">
|
||||
Each line is scored against the submission. Write them as things you could point at
|
||||
in a video — “safe hand position”, not “does it well”.
|
||||
{t('addSkillTraining.eachLineIsScoredAgainstThe')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ListBuilder
|
||||
label="Evaluation criteria"
|
||||
placeholder="Safe hand position"
|
||||
label={t('addSkillTraining.evaluationCriteria')}
|
||||
placeholder={t('addSkillTraining.safeHandPosition')}
|
||||
values={form.criteria}
|
||||
onChange={(c) => set({ criteria: c })}
|
||||
addLabel="Add criterion"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Pass score" hint="The score at or above which the skill is verified.">
|
||||
<Field label={t('addSkillTraining.passScore')} hint={t('addSkillTraining.theScoreAtOrAboveWhich')}>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -370,11 +369,11 @@ export function AddSkillTraining({
|
||||
onChange={(e) => set({ pass_score: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Badge awarded" hint="Optional. Written onto the profile on a pass.">
|
||||
<Field label={t('addSkillTraining.badgeAwarded')} hint={t('addSkillTraining.optionalWrittenOntoTheProfileOn')}>
|
||||
<Input
|
||||
value={form.badge_reward}
|
||||
onChange={(e) => set({ badge_reward: e.target.value })}
|
||||
placeholder="Knife Certified"
|
||||
placeholder={t('addSkillTraining.knifeCertified')}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
@@ -383,33 +382,32 @@ export function AddSkillTraining({
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<Review label="Skill" value={form.title.trim() || '—'} meta={[form.category, form.difficulty].filter(Boolean).join(' · ')} />
|
||||
<Review label={t('addSkillTraining.skill')} value={form.title.trim() || '—'} meta={[form.category, form.difficulty].filter(Boolean).join(' · ')} />
|
||||
<Review
|
||||
label="Training"
|
||||
label={t('addSkillTraining.training')}
|
||||
value={outline.length ? `${outline.length} ${outline.length === 1 ? 'step' : 'steps'}` : 'No written outline'}
|
||||
meta={outline.join(' · ')}
|
||||
/>
|
||||
<Review label="Proof method" value={TYPE_LABEL[form.challengeType]} meta={form.prompt.trim()} />
|
||||
<Review label={t('addSkillTraining.proofMethod')} value={TYPE_LABEL[form.challengeType]} meta={form.prompt.trim()} />
|
||||
<Review
|
||||
label="Owliver evaluation"
|
||||
label={t('addSkillTraining.owliverEvaluation')}
|
||||
value={criteria.length ? `${criteria.length} ${criteria.length === 1 ? 'criterion' : 'criteria'}` : 'None set'}
|
||||
meta={criteria.join(' · ')}
|
||||
/>
|
||||
<Review
|
||||
label="Verification"
|
||||
label={t('addSkillTraining.verification')}
|
||||
value={`Passes at ${Number(form.pass_score) || 70}`}
|
||||
meta={form.badge_reward.trim() ? `Earns ${form.badge_reward.trim()}` : 'No badge'}
|
||||
/>
|
||||
|
||||
{confirming ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning-muted/40 p-3 text-body-sm leading-relaxed text-ink-2">
|
||||
Publishing {editing ? 'keeps' : 'puts'} <span className="font-semibold">{form.title.trim() || 'this skill'}</span>
|
||||
{' '}in front of the workforce straight away. Confirm below, or save it as a draft instead.
|
||||
{t('addSkillTraining.publishing')} {editing ? 'keeps' : 'puts'} <span className="font-semibold">{form.title.trim() || 'this skill'}</span>
|
||||
{' '}{t('addSkillTraining.inFrontOfTheWorkforceStraight')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
Nothing is saved until you choose. A draft stays out of the workforce's view until
|
||||
you publish it.
|
||||
{t('addSkillTraining.nothingIsSavedUntilYouChoose')}h it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ArrowRight, Lock, Search, ShieldCheck } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import { DIFF_TONE, TYPE_ICON, TYPE_LABEL, typeOf } from './challengeMeta';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Every other challenge, as a directory.
|
||||
@@ -44,15 +45,16 @@ export function ChallengeDirectory({
|
||||
completedIds,
|
||||
profile,
|
||||
}: ChallengeDirectoryProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section aria-labelledby="forge-directory" className="min-w-0">
|
||||
<div className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 id="forge-directory" className="text-overline uppercase text-ink-4">
|
||||
Other challenges
|
||||
{t('challengeDirectory.otherChallenges')}
|
||||
</h2>
|
||||
<p className="mt-1 text-body-sm text-ink-3">
|
||||
Practical challenges that turn a claim into verified evidence.
|
||||
{t('challengeDirectory.practicalChallengesThatTurnAClaim')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -66,14 +68,14 @@ export function ChallengeDirectory({
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Search challenges"
|
||||
aria-label="Search challenges"
|
||||
placeholder={t('challengeDirectory.searchChallenges')}
|
||||
aria-label={t('challengeDirectory.searchChallenges')}
|
||||
className="h-8 w-full border-0 border-b border-border bg-transparent pl-6 pr-2 text-body-sm text-ink-1
|
||||
placeholder:text-ink-4 focus:border-krow-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3" role="radiogroup" aria-label="Challenge difficulty">
|
||||
<div className="flex flex-wrap items-center gap-3" role="radiogroup" aria-label={t('challengeDirectory.challengeDifficulty')}>
|
||||
{difficulties.map((d) => {
|
||||
const active = d.v === difficulty;
|
||||
return (
|
||||
@@ -109,7 +111,7 @@ export function ChallengeDirectory({
|
||||
className="rounded text-caption font-semibold text-krow-blue hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
Clear filters
|
||||
{t('challengeDirectory.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -124,14 +126,14 @@ export function ChallengeDirectory({
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="border-t border-border py-10 text-center">
|
||||
<p className="text-body-sm text-ink-3">No challenges match your filters.</p>
|
||||
<p className="text-body-sm text-ink-3">{t('challengeDirectory.noChallengesMatchYourFilters')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="mt-2 rounded text-body-sm font-semibold text-krow-blue hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
Clear filters
|
||||
{t('challengeDirectory.clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -184,7 +186,7 @@ export function ChallengeDirectory({
|
||||
</span>
|
||||
{locked && lock.reasons?.length > 0 && (
|
||||
<span className="mt-1 block truncate text-caption text-warning">
|
||||
Requires {lock.reasons[0]}
|
||||
{t('challengeDirectory.requires')} {lock.reasons[0]}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Plus, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The Forge masthead, written for the person who builds the library.
|
||||
@@ -28,21 +29,21 @@ interface ForgeHeaderProps {
|
||||
}
|
||||
|
||||
export function ForgeHeader({ onCreate = null, _onBrowse = null }: ForgeHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<header className="flex flex-col gap-4 border-b border-border/60 pb-6 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-bold text-blue-600 dark:bg-blue-950 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Workforce Skill Verification
|
||||
{t('forgeHeader.workforceSkillVerification')}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-heading text-display-lg font-bold leading-none tracking-tight text-ink-1 sm:text-[2.25rem]">
|
||||
KROW Forge
|
||||
{t('forgeHeader.krowForge')}
|
||||
</h1>
|
||||
<p className="max-w-xl text-body leading-relaxed text-ink-3">
|
||||
Create skills your workforce can learn, practice and prove. Build practical training
|
||||
and evidence-based challenges that turn real-world work into verified workforce skills.
|
||||
{t('forgeHeader.createSkillsYourWorkforceCanLearn')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +55,7 @@ export function ForgeHeader({ onCreate = null, _onBrowse = null }: ForgeHeaderPr
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>Add Skill Training</span>
|
||||
<span>{t('forgeHeader.addSkillTraining')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { ArrowRight, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Forge Insight — AI Career Insight Card
|
||||
@@ -13,6 +14,7 @@ interface ForgeInsightProps {
|
||||
}
|
||||
|
||||
export function ForgeInsight({ headline, detail, highlight, actionLabel, onAction }: ForgeInsightProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<aside className="relative overflow-hidden rounded-2xl border border-blue-500/30 bg-gradient-to-r from-blue-50/80 via-surface to-indigo-50/30 dark:from-blue-950/40 dark:via-surface dark:to-indigo-950/30 p-5 shadow-xs">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -20,12 +22,12 @@ export function ForgeInsight({ headline, detail, highlight, actionLabel, onActio
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">
|
||||
KROW Forge Insight
|
||||
{t('forgeInsight.krowForgeInsight')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-heading text-body-lg font-bold text-ink-1">{headline}</p>
|
||||
<p className="max-w-2xl text-body-sm leading-relaxed text-ink-3">
|
||||
Next best skill: <span className="font-semibold text-blue-600 dark:text-blue-400">{highlight}</span>. {detail}
|
||||
{t('forgeInsight.nextBestSkill')} <span className="font-semibold text-blue-600 dark:text-blue-400">{highlight}</span>. {detail}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { BookOpen, Layers, ShieldCheck, Target, TrendingUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Skill overview — progression, in counted figures.
|
||||
@@ -79,13 +80,14 @@ interface ForgeProgressProps {
|
||||
}
|
||||
|
||||
export function ForgeProgress({ figures = [], loading = false }: ForgeProgressProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section aria-labelledby="forge-progress" className="space-y-4 rounded-2xl border border-border/80 bg-surface p-5 sm:p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-2 w-2 rounded-full bg-blue-600" />
|
||||
<h2 id="forge-progress" className="font-heading text-caption font-bold uppercase tracking-wider text-ink-4">
|
||||
Skill Overview
|
||||
{t('forgeProgress.skillOverview')}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Plus, CheckCircle2, Lightbulb, History, Layers } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { statusOf } from './challengeMeta';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const CREATE_STEPS = [
|
||||
'Define skill',
|
||||
@@ -27,14 +28,15 @@ interface ForgeRailProps {
|
||||
}
|
||||
|
||||
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver: _onAskOwliver }: ForgeRailProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<aside
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3 items-stretch"
|
||||
aria-label="Forge guidance"
|
||||
aria-label={t('forgeRail.forgeGuidance')}
|
||||
>
|
||||
{/* 1. Create Skill Card */}
|
||||
<Panel
|
||||
title="Create Skill"
|
||||
title={t('forgeRail.createSkill')}
|
||||
icon={Layers}
|
||||
iconColor="text-blue-600"
|
||||
>
|
||||
@@ -54,14 +56,14 @@ export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver: _onAskO
|
||||
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-caption font-bold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Start a new skill
|
||||
{t('forgeRail.startANewSkill')}
|
||||
</button>
|
||||
</Panel>
|
||||
|
||||
|
||||
{/* 3. Quick Tips Card */}
|
||||
<Panel
|
||||
title="Quick Tips"
|
||||
title={t('forgeRail.quickTips')}
|
||||
icon={Lightbulb}
|
||||
iconColor="text-amber-500"
|
||||
>
|
||||
@@ -78,7 +80,7 @@ export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver: _onAskO
|
||||
{/* 4. Recent Skills Card */}
|
||||
{recent.length > 0 && (
|
||||
<Panel
|
||||
title="Recent Skills"
|
||||
title={t('forgeRail.recentSkills')}
|
||||
icon={History}
|
||||
iconColor="text-purple-600"
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ArrowRight, Check, Lock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf, proofSteps } from './challengeMeta';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The next skill to prove — the focal point of the page and its only surface.
|
||||
@@ -62,6 +63,7 @@ interface NextSkillProps {
|
||||
}
|
||||
|
||||
export function NextSkill({ course, locked, requirements = [], actionLabel, index = 1 }: NextSkillProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!course) return null;
|
||||
|
||||
const type = typeOf(course);
|
||||
@@ -87,7 +89,7 @@ export function NextSkill({ course, locked, requirements = [], actionLabel, inde
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<p className="text-overline uppercase tracking-[0.12em] text-krow-blue">
|
||||
Next skill to prove
|
||||
{t('nextSkill.nextSkillToProve')}
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -121,7 +123,7 @@ export function NextSkill({ course, locked, requirements = [], actionLabel, inde
|
||||
{/* What the proof is judged on. */}
|
||||
{steps.length > 0 && (
|
||||
<div className="bg-surface p-5 sm:p-7">
|
||||
<p className="text-overline uppercase text-ink-4">What you’ll prove</p>
|
||||
<p className="text-overline uppercase text-ink-4">{t('nextSkill.whatYoullProve')}</p>
|
||||
<ol className="mt-3.5">
|
||||
{steps.map((step, i) => (
|
||||
<li key={step} className="flex items-baseline gap-3 border-b border-border py-2.5 last:border-0 last:pb-0">
|
||||
@@ -149,7 +151,7 @@ export function NextSkill({ course, locked, requirements = [], actionLabel, inde
|
||||
|
||||
{/* How it is submitted, and who grades it. */}
|
||||
<div className="bg-surface p-5 sm:p-7">
|
||||
<p className="text-overline uppercase text-ink-4">How you’ll prove it</p>
|
||||
<p className="text-overline uppercase text-ink-4">{t('nextSkill.howYoullProveIt')}</p>
|
||||
|
||||
<p className="mt-3.5 flex items-center gap-2.5 font-heading text-body-lg font-semibold text-ink-1">
|
||||
<Icon className="h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
|
||||
@@ -211,7 +213,7 @@ export function NextSkill({ course, locked, requirements = [], actionLabel, inde
|
||||
<div className="border-t border-border bg-warning-muted/40 px-5 py-5 sm:px-7">
|
||||
<p className="flex items-center gap-2 text-overline uppercase text-warning">
|
||||
<Lock className="h-3 w-3" aria-hidden="true" />
|
||||
Locked for now
|
||||
{t('nextSkill.lockedForNow')}
|
||||
</p>
|
||||
<p className="mt-1.5 text-body-sm text-ink-2">
|
||||
{outstanding === 1
|
||||
@@ -259,7 +261,7 @@ export function NextSkill({ course, locked, requirements = [], actionLabel, inde
|
||||
{/* Quiet metadata, and the action. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-4 sm:px-7">
|
||||
<p className="text-caption tabular-nums text-ink-3">
|
||||
{course.estimated_minutes} min · {course.xp} XP
|
||||
{course.estimated_minutes} {t('nextSkill.min')} {course.xp} XP
|
||||
{course.badge_reward && ` · Earns ${course.badge_reward}`}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowRight, Clock, Sparkles, BookOpen, ShieldCheck, Flame, Wine, Users,
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ds';
|
||||
import { unlocksLabel } from '@/lib/skillGraph';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, TYPE_ICON, TYPE_LABEL, trainingType, typeOf,
|
||||
} from './challengeMeta';
|
||||
@@ -60,6 +61,7 @@ interface SkillCardProps {
|
||||
}
|
||||
|
||||
export function SkillCard({ course, onOpen, moduleState }: SkillCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const status = statusOf(course);
|
||||
const type = typeOf(course);
|
||||
const ProofIcon = TYPE_ICON[type] || TYPE_ICON.roleplay;
|
||||
@@ -165,7 +167,7 @@ export function SkillCard({ course, onOpen, moduleState }: SkillCardProps) {
|
||||
<div className="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-blue-50/80 via-indigo-50/50 to-purple-50/30 dark:from-blue-950/40 dark:via-indigo-950/30 dark:to-purple-950/20 px-2 py-1 border border-blue-200/80 dark:border-blue-800/60">
|
||||
<Sparkles className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" aria-hidden="true" />
|
||||
<span className="text-[11px] font-semibold text-blue-900 dark:text-blue-300">
|
||||
Owliver evaluated
|
||||
{t('skillCard.owliverEvaluated')}
|
||||
</span>
|
||||
<span className="text-[11px] font-normal text-blue-700/80 dark:text-blue-400/80">
|
||||
· {criteria.length} {criteria.length === 1 ? 'criterion' : 'criteria'}
|
||||
@@ -185,7 +187,7 @@ export function SkillCard({ course, onOpen, moduleState }: SkillCardProps) {
|
||||
className="flex min-w-0 flex-1 items-center gap-1 truncate text-[10px] font-bold uppercase tracking-wider text-ink-4"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 shrink-0 text-emerald-500" />
|
||||
<span className="truncate">Unlocks {unlocks}</span>
|
||||
<span className="truncate">{t('skillCard.unlocks')} {unlocks}</span>
|
||||
</span>
|
||||
) : course.proof_skill ? (
|
||||
<span
|
||||
@@ -193,7 +195,7 @@ export function SkillCard({ course, onOpen, moduleState }: SkillCardProps) {
|
||||
className="min-w-0 flex-1 truncate text-[10px] font-bold uppercase tracking-wider text-ink-4 flex items-center gap-1"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500 shrink-0" />
|
||||
<span className="truncate">Verifies {course.proof_skill}</span>
|
||||
<span className="truncate">{t('skillCard.verifies')} {course.proof_skill}</span>
|
||||
</span>
|
||||
) : <span />}
|
||||
|
||||
@@ -205,7 +207,7 @@ export function SkillCard({ course, onOpen, moduleState }: SkillCardProps) {
|
||||
}}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-lg bg-blue-50 dark:bg-blue-950 px-2.5 py-1 text-caption font-bold text-blue-600 dark:text-blue-400 transition-all hover:bg-blue-600 hover:text-white group-hover:shadow-sm"
|
||||
>
|
||||
View training
|
||||
{t('skillCard.viewTraining')}
|
||||
<ArrowRight
|
||||
className="h-3 w-3 transition-transform duration-200 group-hover:translate-x-1"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, Check, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge, Drawer } from '@/components/ds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, trainingKinds, trainingOutline,
|
||||
TYPE_HINT, TYPE_LABEL, typeOf,
|
||||
@@ -30,6 +31,7 @@ interface SkillDetailProps {
|
||||
}
|
||||
|
||||
export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!course) return null;
|
||||
|
||||
const status = statusOf(course);
|
||||
@@ -59,7 +61,7 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
|
||||
<span className="text-caption text-ink-4">{course.estimated_minutes} min</span>
|
||||
)}
|
||||
{course.proof_skill && (
|
||||
<span className="text-caption text-ink-4">· Verifies {course.proof_skill}</span>
|
||||
<span className="text-caption text-ink-4">{t('skillDetail.verifies')} {course.proof_skill}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -67,7 +69,7 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">{course.description}</p>
|
||||
)}
|
||||
|
||||
<Block title="Training" meta="What the workforce learns">
|
||||
<Block title={t('skillDetail.training')} meta={t('skillDetail.whatTheWorkforceLearns')}>
|
||||
{outline.length > 0 ? (
|
||||
<ol className="space-y-2">
|
||||
{outline.map((step, i) => (
|
||||
@@ -81,7 +83,7 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
This skill carries no written outline. It teaches through
|
||||
{t('skillDetail.thisSkillCarriesNoWrittenOutline')}
|
||||
{' '}
|
||||
{kinds.length ? kinds.join(' and ').toLowerCase() : 'the challenge itself'}.
|
||||
</p>
|
||||
@@ -89,13 +91,13 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
|
||||
|
||||
{course.quiz?.length > 0 && (
|
||||
<p className="mt-3 text-caption text-ink-4">
|
||||
Includes a {course.quiz.length}-question knowledge check.
|
||||
{t('skillDetail.includesA')} {course.quiz.length}{t('skillDetail.questionKnowledgeCheck')}
|
||||
</p>
|
||||
)}
|
||||
</Block>
|
||||
|
||||
{course.challenge && (
|
||||
<Block title="Proof" meta="How the workforce demonstrates the skill">
|
||||
<Block title={t('skillDetail.proof')} meta={t('skillDetail.howTheWorkforceDemonstratesTheSkill')}>
|
||||
<p className="text-body-sm font-semibold text-ink-1">{TYPE_LABEL[type]}</p>
|
||||
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">{TYPE_HINT[type]}</p>
|
||||
{course.challenge.prompt && (
|
||||
@@ -108,8 +110,8 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
|
||||
|
||||
{criteria.length > 0 && (
|
||||
<Block
|
||||
title="Owliver evaluation"
|
||||
meta="What Owliver checks"
|
||||
title={t('skillDetail.owliverEvaluation')}
|
||||
meta={t('skillDetail.whatOwliverChecks')}
|
||||
icon={Sparkles}
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
@@ -129,12 +131,12 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
|
||||
)}
|
||||
|
||||
{course.pass_score > 0 && (
|
||||
<Block title="Verification" meta="What qualifies as passing">
|
||||
<Block title={t('skillDetail.verification')} meta={t('skillDetail.whatQualifiesAsPassing')}>
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||
A submission verifies the skill at{' '}
|
||||
<span className="font-semibold text-ink-1">{course.pass_score} or above</span>
|
||||
{t('skillDetail.aSubmissionVerifiesTheSkillAt')}{' '}
|
||||
<span className="font-semibold text-ink-1">{course.pass_score} {t('skillDetail.orAbove')}</span>
|
||||
{course.badge_reward
|
||||
? <>, and earns the <span className="font-semibold text-ink-1">{course.badge_reward}</span> badge.</>
|
||||
? <>{t('skillDetail.andEarnsThe')} <span className="font-semibold text-ink-1">{course.badge_reward}</span> {t('skillDetail.badge')}</>
|
||||
: '.'}
|
||||
</p>
|
||||
</Block>
|
||||
@@ -144,7 +146,7 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
|
||||
have no field behind them, so the two states the records do carry are
|
||||
the two states shown. */}
|
||||
{usage && (usage.completed > 0 || usage.verified > 0) && (
|
||||
<Block title="Workforce usage" meta="From worker profiles">
|
||||
<Block title={t('skillDetail.workforceUsage')} meta={t('skillDetail.fromWorkerProfiles')}>
|
||||
<dl className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'Completed', value: usage.completed },
|
||||
@@ -164,7 +166,7 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
|
||||
className="group inline-flex items-center gap-1.5 rounded text-body-sm font-semibold text-krow-blue hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
Open the challenge as the workforce sees it
|
||||
{t('skillDetail.openTheChallengeAsTheWorkforce')}
|
||||
<ArrowRight
|
||||
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SkillCard } from './SkillCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The Skill Training Library — the working surface of admin Forge.
|
||||
@@ -60,15 +61,16 @@ export function SkillLibrary({
|
||||
sectionRef,
|
||||
moduleStateFor,
|
||||
}: SkillLibraryProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section aria-labelledby="skill-library" className="min-w-0" ref={sectionRef}>
|
||||
<div className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 id="skill-library" className="font-heading text-title font-bold text-ink-1">
|
||||
Skill Training Library
|
||||
{t('skillLibrary.skillTrainingLibrary')}
|
||||
</h2>
|
||||
<p className="mt-1 text-body-sm text-ink-3">
|
||||
Build practical training and challenges for the skills your workforce needs.
|
||||
{t('skillLibrary.buildPracticalTrainingAndChallengesFor')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -82,21 +84,21 @@ export function SkillLibrary({
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Search skills"
|
||||
aria-label="Search skills"
|
||||
placeholder={t('skillLibrary.searchSkills')}
|
||||
aria-label={t('skillLibrary.searchSkills')}
|
||||
className="h-8 w-full border-0 border-b border-border bg-transparent pl-6 pr-2 text-body-sm text-ink-1
|
||||
placeholder:text-ink-4 focus:border-krow-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FilterGroup
|
||||
label="Skill category"
|
||||
label={t('skillLibrary.skillCategory')}
|
||||
options={categories}
|
||||
value={category}
|
||||
onChange={onCategory}
|
||||
/>
|
||||
<FilterGroup
|
||||
label="Publication status"
|
||||
label={t('skillLibrary.publicationStatus')}
|
||||
options={statuses}
|
||||
value={status}
|
||||
onChange={onStatus}
|
||||
@@ -115,7 +117,7 @@ export function SkillLibrary({
|
||||
className="rounded text-caption font-semibold text-krow-blue hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
Clear filters
|
||||
{t('skillLibrary.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -145,7 +147,7 @@ export function SkillLibrary({
|
||||
{filtered ? 'Clear filters' : (
|
||||
<>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Add Skill Training
|
||||
{t('skillLibrary.addSkillTraining')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowRight, Check, Circle, Lock, ShieldCheck, Sparkles } from 'lucide-r
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge, Button, Drawer, ProgressBar } from '@/components/ds';
|
||||
import { levelLabel } from '@/lib/skillGraph';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Skill progression — a skill as a ladder rather than a pile of courses.
|
||||
@@ -98,6 +99,7 @@ interface SkillProgressCardProps {
|
||||
}
|
||||
|
||||
export function SkillProgressCard({ state, onOpen }: SkillProgressCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const status = STATUS_META[state.status] || STATUS_META.not_started;
|
||||
const atTop = !state.nextLevel;
|
||||
|
||||
@@ -122,7 +124,7 @@ export function SkillProgressCard({ state, onOpen }: SkillProgressCardProps) {
|
||||
<p className="mt-0.5 flex items-center gap-1 text-caption text-ink-3">
|
||||
{state.verifiedLevel && <ShieldCheck className="h-3 w-3 text-success" aria-hidden="true" />}
|
||||
{state.verifiedLabel}
|
||||
{state.verifiedByOverride && <span className="text-ink-4">· admin verified</span>}
|
||||
{state.verifiedByOverride && <span className="text-ink-4">{t('skillProgression.adminVerified')}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={status.variant} size="sm">{status.label}</Badge>
|
||||
@@ -132,11 +134,11 @@ export function SkillProgressCard({ state, onOpen }: SkillProgressCardProps) {
|
||||
|
||||
<div className="mb-4 mt-4">
|
||||
{atTop ? (
|
||||
<p className="text-caption text-ink-3">Top of the ladder — every module verified.</p>
|
||||
<p className="text-caption text-ink-3">{t('skillProgression.topOfTheLadderEveryModule')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-caption text-ink-2">Progress to {levelLabel(state.nextLevel)}</span>
|
||||
<span className="truncate text-caption text-ink-2">{t('skillProgression.progressTo')} {levelLabel(state.nextLevel)}</span>
|
||||
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
|
||||
{state.completedInNext}/{state.requiredForNext}
|
||||
</span>
|
||||
@@ -152,13 +154,13 @@ export function SkillProgressCard({ state, onOpen }: SkillProgressCardProps) {
|
||||
<p className="min-w-0 text-caption text-ink-3">
|
||||
{state.nextRequirement ? (
|
||||
<>
|
||||
<span className="text-ink-4">Next</span>{' '}
|
||||
<span className="text-ink-4">{t('skillProgression.next')}</span>{' '}
|
||||
<span className="font-medium text-ink-2">{state.nextRequirement.title}</span>
|
||||
</>
|
||||
) : atTop ? 'Nothing outstanding' : 'No training authored for this level yet'}
|
||||
</p>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-caption font-semibold text-krow-blue">
|
||||
View
|
||||
{t('skillProgression.view')}
|
||||
<ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
@@ -181,6 +183,7 @@ interface SkillProgressDetailProps {
|
||||
}
|
||||
|
||||
export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining }: SkillProgressDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!state) return null;
|
||||
|
||||
const done = new Set(state.levels.flatMap((l) => l.completed.map((m) => m.id)));
|
||||
@@ -196,11 +199,11 @@ export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining
|
||||
footer={
|
||||
next ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
<Button size="sm" onClick={() => onStartTraining(next)}>Start training</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>{t('skillProgression.close')}</Button>
|
||||
<Button size="sm" onClick={() => onStartTraining(next)}>{t('skillProgression.startTraining')}</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>{t('skillProgression.close')}</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -211,7 +214,7 @@ export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining
|
||||
{state.nextLevel && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<span className="text-caption text-ink-2">Progress to {levelLabel(state.nextLevel)}</span>
|
||||
<span className="text-caption text-ink-2">{t('skillProgression.progressTo')} {levelLabel(state.nextLevel)}</span>
|
||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{state.progressToNext}%</span>
|
||||
</div>
|
||||
<ProgressBar value={state.progressToNext} tone="brand" size="sm" />
|
||||
@@ -220,7 +223,7 @@ export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining
|
||||
{state.verifiedByOverride && (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-caption text-ink-3">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
|
||||
{levelLabel(state.verifiedByOverride.level)} verified manually by {state.verifiedByOverride.verified_by || 'an administrator'}.
|
||||
{levelLabel(state.verifiedByOverride.level)} {t('skillProgression.verifiedManuallyBy')} {state.verifiedByOverride.verified_by || 'an administrator'}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -230,7 +233,7 @@ export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining
|
||||
<section key={rung.level}>
|
||||
<div className="mb-2 flex items-baseline justify-between gap-2">
|
||||
<h3 className="font-heading text-caption font-bold uppercase tracking-wide text-ink-4">
|
||||
Level {i + 1} — {rung.label}
|
||||
{t('skillProgression.level')} {i + 1} — {rung.label}
|
||||
</h3>
|
||||
<span className="text-caption text-ink-4 tabular-nums">
|
||||
{rung.completed.length}/{rung.modules.length}
|
||||
@@ -263,14 +266,14 @@ export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining
|
||||
{module.title}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-ink-4">
|
||||
{module.estimated_minutes} min · {(module.verification_criteria || []).length} verification criteria
|
||||
{module.estimated_minutes} {t('skillProgression.min')} {(module.verification_criteria || []).length} {t('skillProgression.verificationCriteria')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{complete ? (
|
||||
<span className="shrink-0 text-[11px] font-semibold text-success">Verified</span>
|
||||
<span className="shrink-0 text-[11px] font-semibold text-success">{t('skillProgression.verified')}</span>
|
||||
) : isNext ? (
|
||||
<Button size="xs" variant="outline" onClick={() => onStartTraining(module)}>Start</Button>
|
||||
<Button size="xs" variant="outline" onClick={() => onStartTraining(module)}>{t('skillProgression.start')}</Button>
|
||||
) : (
|
||||
<span className="shrink-0 text-[11px] text-ink-4">{locked ? 'Locked' : 'Available'}</span>
|
||||
)}
|
||||
@@ -279,7 +282,7 @@ export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">No training authored for this level yet.</p>
|
||||
<p className="text-body-sm text-ink-3">{t('skillProgression.noTrainingAuthoredForThisLevel')}</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
@@ -288,10 +291,9 @@ export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining
|
||||
<div className="flex items-start gap-2.5 rounded-xl border border-krow-blue/30 bg-krow-blue-tint/50 p-4">
|
||||
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-semibold text-ink-1">Next milestone</p>
|
||||
<p className="text-body-sm font-semibold text-ink-1">{t('skillProgression.nextMilestone')}</p>
|
||||
<p className="text-caption text-ink-2">
|
||||
Complete {next.title} to reach {state.name} — {levelLabel(next.target_level)}.
|
||||
Owliver verifies the evidence against {(next.verification_criteria || []).length} criteria.
|
||||
{t('skillProgression.complete')} {next.title} {t('skillProgression.toReach')} {state.name} — {levelLabel(next.target_level)}{t('skillProgression.owliverVerifiesTheEvidenceAgainst')} {(next.verification_criteria || []).length} {t('skillProgression.criteria')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ArrowRight, Award, Check, ChevronRight, Compass, FileText, ShieldCheck, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Your Forge — Modern Process Workflow Cards & Skill Verification Roster
|
||||
@@ -21,6 +22,7 @@ interface YourForgeProps {
|
||||
}
|
||||
|
||||
export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate }: YourForgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const empty = skills.length === 0;
|
||||
|
||||
return (
|
||||
@@ -29,7 +31,7 @@ export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h2 id="your-forge" className="font-heading text-title font-bold text-ink-1">
|
||||
Your Verified Skills
|
||||
{t('yourForge.yourVerifiedSkills')}
|
||||
</h2>
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-bold text-blue-600 dark:bg-blue-950 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
{skills.length} {skills.length === 1 ? 'Skill' : 'Skills'}
|
||||
@@ -47,7 +49,7 @@ export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate
|
||||
onClick={onBrowse}
|
||||
className="group inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 hover:bg-blue-700 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<span>Browse Challenges</span>
|
||||
<span>{t('yourForge.browseChallenges')}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -77,7 +79,7 @@ export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate
|
||||
isFirst ? 'text-blue-600 dark:text-blue-400' : 'text-ink-4'
|
||||
)}
|
||||
>
|
||||
Step {item.step}
|
||||
{t('yourForge.step')} {item.step}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { generateInterviewQuestion, evaluateInterview } from '@/lib/krowAi';
|
||||
import { useCreateInterview } from '@/lib/krowHooks';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const TOTAL_SECONDS = 180;
|
||||
const QUESTION_COUNT = 5;
|
||||
@@ -20,6 +21,7 @@ interface AIInterviewModalProps {
|
||||
}
|
||||
|
||||
export default function AIInterviewModal({ open, onClose, application, job, conductor = 'KROW' }: AIInterviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [phase, setPhase] = useState('intro'); // intro | active | evaluating | done
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
@@ -263,7 +265,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-[13px] font-bold">K</div>
|
||||
)}
|
||||
AI Interview with {conductor}
|
||||
{t('aIInterviewModal.aiInterviewWith')} {conductor}
|
||||
</span>
|
||||
{phase === 'active' && (
|
||||
<span className={cn('text-[13px] font-mono px-2 py-1 rounded-lg', timeLeft < 30 ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#F3F4F6] text-[#374151]')}>
|
||||
@@ -280,14 +282,14 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-2xl font-bold mx-auto mb-4">K</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-[#111827] mb-2">Hi {candidateName.split(' ')[0]}, I'm {conductor}</h3>
|
||||
<h3 className="text-lg font-semibold text-[#111827] mb-2">{t('aIInterviewModal.hi')} {candidateName.split(' ')[0]}, I'm {conductor}</h3>
|
||||
<p className="text-[14px] text-[#6B7280] max-w-md mx-auto mb-6">
|
||||
{language === 'es'
|
||||
? `Te haré ${QUESTION_COUNT} preguntas rápidas sobre tu experiencia en roles de ${jobTitle}. Toma unos 3 minutos. Solo habla naturalmente — no hay respuestas correctas o incorrectas.`
|
||||
: `I'll ask you ${QUESTION_COUNT} quick questions about your experience with ${jobTitle} roles. This takes about 3 minutes. Just speak naturally — there are no right or wrong answers.`}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3 mb-4 text-[12px] text-[#6B7280]">
|
||||
<span className="inline-flex items-center gap-1"><Volume2 className="w-3.5 h-3.5" /> Voice enabled</span>
|
||||
<span className="inline-flex items-center gap-1"><Volume2 className="w-3.5 h-3.5" /> {t('aIInterviewModal.voiceEnabled')}</span>
|
||||
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="text-[#0838E0] underline">
|
||||
{ttsEnabled ? 'Mute' : 'Unmute'}
|
||||
</button>
|
||||
@@ -295,7 +297,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
|
||||
{/* Language selector */}
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<span className="text-[12px] text-[#6B7280] font-medium">Interview language:</span>
|
||||
<span className="text-[12px] text-[#6B7280] font-medium">{t('aIInterviewModal.interviewLanguage')}</span>
|
||||
<div className="inline-flex rounded-lg border border-[#E5E7EB] overflow-hidden">
|
||||
<button
|
||||
onClick={() => setLanguage('en')}
|
||||
@@ -304,7 +306,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
language === 'en' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
|
||||
)}
|
||||
>
|
||||
English
|
||||
{t('aIInterviewModal.english')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLanguage('es')}
|
||||
@@ -313,13 +315,13 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
language === 'es' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
|
||||
)}
|
||||
>
|
||||
Español
|
||||
{t('aIInterviewModal.espaOl')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={startInterview} className="bg-[#0838E0] hover:bg-[#062BAF] px-8">
|
||||
<Mic className="w-4 h-4 mr-2" /> Start Interview
|
||||
<Mic className="w-4 h-4 mr-2" /> {t('aIInterviewModal.startInterview')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -338,7 +340,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
<p>{msg.content}</p>
|
||||
{msg.response_time_seconds != null && msg.response_time_seconds < 8 && (
|
||||
<p className="text-[10px] mt-1 opacity-70 inline-flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Fast response flagged
|
||||
<AlertTriangle className="w-3 h-3" /> {t('aIInterviewModal.fastResponseFlagged')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -387,7 +389,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
disabled={!transcript.trim()}
|
||||
className="bg-[#0838E0] hover:bg-[#062BAF]"
|
||||
>
|
||||
Send Answer
|
||||
{t('aIInterviewModal.sendAnswer')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,7 +399,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
{phase === 'evaluating' && (
|
||||
<div className="py-12 text-center">
|
||||
<Loader2 className="w-10 h-10 text-[#0838E0] animate-spin mx-auto mb-4" />
|
||||
<p className="text-[14px] text-[#6B7280]">{conductor} is evaluating your interview...</p>
|
||||
<p className="text-[14px] text-[#6B7280]">{conductor} {t('aIInterviewModal.isEvaluatingYourInterview')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -410,7 +412,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
)}>
|
||||
{evaluation.overall_interview_score}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[#111827] capitalize">Verdict: {evaluation.verdict}</h3>
|
||||
<h3 className="text-lg font-semibold text-[#111827] capitalize">{t('aIInterviewModal.verdict')} {evaluation.verdict}</h3>
|
||||
<p className="text-[13px] text-[#6B7280] mt-1">{evaluation.hire_recommendation}</p>
|
||||
</div>
|
||||
|
||||
@@ -427,7 +429,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
<div className="flex items-start gap-2 p-3 bg-[#FEF3C7] rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-[#92400E] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">Integrity Score: {evaluation.integrity_score}/100</p>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">{t('aIInterviewModal.integrityScore')} {evaluation.integrity_score}/100</p>
|
||||
{evaluation.ai_flags?.length > 0 && (
|
||||
<p className="text-[11px] text-[#92400E] mt-1">{evaluation.ai_flags.join(', ')}</p>
|
||||
)}
|
||||
@@ -436,13 +438,13 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-1">Summary</p>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-1">{t('aIInterviewModal.summary')}</p>
|
||||
<p className="text-[13px] text-[#6B7280]">{evaluation.summary}</p>
|
||||
</div>
|
||||
|
||||
{evaluation.strengths?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#059669] mb-1 flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> Strengths</p>
|
||||
<p className="text-[12px] font-medium text-[#059669] mb-1 flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> {t('aIInterviewModal.strengths')}</p>
|
||||
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
|
||||
{evaluation.strengths.map((s, i) => <li key={i}>{s}</li>)}
|
||||
</ul>
|
||||
@@ -451,14 +453,14 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
||||
|
||||
{evaluation.concerns?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#EF4444] mb-1 flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" /> Concerns</p>
|
||||
<p className="text-[12px] font-medium text-[#EF4444] mb-1 flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" /> {t('aIInterviewModal.concerns')}</p>
|
||||
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
|
||||
{evaluation.concerns.map((c, i) => <li key={i}>{c}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleClose} className="w-full bg-[#0838E0] hover:bg-[#062BAF]">Done</Button>
|
||||
<Button onClick={handleClose} className="w-full bg-[#0838E0] hover:bg-[#062BAF]">{t('aIInterviewModal.done')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { UserActivity } from '@/types/entities';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
@@ -33,6 +34,7 @@ interface ActivityChartsProps {
|
||||
}
|
||||
|
||||
export default function ActivityCharts({ activities }: ActivityChartsProps) {
|
||||
const { t } = useTranslation();
|
||||
const timeSeries = useMemo(() => {
|
||||
const days = last7Days();
|
||||
return days.map((day) => {
|
||||
@@ -65,7 +67,7 @@ export default function ActivityCharts({ activities }: ActivityChartsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Activity (Last 7 Days)</h3>
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">{t('activityCharts.activityLast7Days')}</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={timeSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
@@ -81,7 +83,7 @@ export default function ActivityCharts({ activities }: ActivityChartsProps) {
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Actions by Type</h3>
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">{t('activityCharts.actionsByType')}</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={byType}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
@@ -94,7 +96,7 @@ export default function ActivityCharts({ activities }: ActivityChartsProps) {
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Users by Role</h3>
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">{t('activityCharts.usersByRole')}</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={byRole} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { UserActivity } from '@/types/entities';
|
||||
import React, { useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const EVENT_LABELS = {
|
||||
signup: 'Sign Up',
|
||||
@@ -42,6 +43,7 @@ interface ActivityLogTableProps {
|
||||
}
|
||||
|
||||
export default function ActivityLogTable({ activities }: ActivityLogTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
@@ -62,14 +64,14 @@ export default function ActivityLogTable({ activities }: ActivityLogTableProps)
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] overflow-hidden">
|
||||
<div className="p-4 border-b border-[#E5E7EB] flex flex-col sm:flex-row gap-3 sm:items-center justify-between">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827]">Activity Log</h3>
|
||||
<h3 className="text-[14px] font-semibold text-[#111827]">{t('activityLogTable.activityLog')}</h3>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#9CA3AF]" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search user..."
|
||||
placeholder={t('activityLogTable.searchUser')}
|
||||
className="pl-8 pr-3 h-8 text-[12px] rounded-md border border-[#E5E7EB] focus:border-[#2563EB] focus:outline-none w-40"
|
||||
/>
|
||||
</div>
|
||||
@@ -90,18 +92,18 @@ export default function ActivityLogTable({ activities }: ActivityLogTableProps)
|
||||
<table className="w-full min-w-[40rem] text-[13px]">
|
||||
<thead className="bg-[#F9FAFB] text-[#6B7280]">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">Event</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">User</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Role</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Details</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Time</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">{t('activityLogTable.event')}</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">{t('activityLogTable.user')}</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">{t('activityLogTable.role')}</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">{t('activityLogTable.details')}</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">{t('activityLogTable.time')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#F3F4F6]">
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-[#9CA3AF]">
|
||||
No activity found
|
||||
{t('activityLogTable.noActivityFound')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Badge, Button, EmptyState, Modal, StatusBadge, toast } from '@/components/ds';
|
||||
import { useCreateApplication, useJobPostings } from '@/lib/krowHooks';
|
||||
import { remainingFor } from '@/lib/hiringRecords';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Put a person from the talent pool in front of a vacancy.
|
||||
@@ -33,6 +34,7 @@ interface AddToPositionModalProps {
|
||||
}
|
||||
|
||||
export default function AddToPositionModal({ open, onOpenChange, profile, existing = [], staff = [] }: AddToPositionModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data: postings = [], isLoading } = useJobPostings();
|
||||
const createApplication = useCreateApplication();
|
||||
@@ -97,7 +99,7 @@ export default function AddToPositionModal({ open, onOpenChange, profile, existi
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={profile ? `Consider ${profile.full_name} for a position` : 'Consider for a position'}
|
||||
description="This files an application against the vacancy and starts its hiring pipeline. The talent-pool record is not copied."
|
||||
description={t('addToPositionModal.thisFilesAnApplicationAgainstThe')}
|
||||
size="lg"
|
||||
busy={Boolean(busyId)}
|
||||
>
|
||||
@@ -105,9 +107,9 @@ export default function AddToPositionModal({ open, onOpenChange, profile, existi
|
||||
|
||||
{!isLoading && open_.length === 0 && (
|
||||
<EmptyState
|
||||
title="No open positions"
|
||||
description="A position has to be open before anyone can be considered for it."
|
||||
action={<Button variant="outline" onClick={() => { onOpenChange?.(false); navigate('/admin/positions'); }}>Go to Positions</Button>}
|
||||
title={t('addToPositionModal.noOpenPositions')}
|
||||
description={t('addToPositionModal.aPositionHasToBeOpen')}
|
||||
action={<Button variant="outline" onClick={() => { onOpenChange?.(false); navigate('/admin/positions'); }}>{t('addToPositionModal.goToPositions')}</Button>}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -131,7 +133,7 @@ export default function AddToPositionModal({ open, onOpenChange, profile, existi
|
||||
|
||||
{already ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant="neutral" size="sm">Already considered</Badge>
|
||||
<Badge variant="neutral" size="sm">{t('addToPositionModal.alreadyConsidered')}</Badge>
|
||||
<StatusBadge status={already.status} size="sm" />
|
||||
</div>
|
||||
) : (
|
||||
@@ -141,7 +143,7 @@ export default function AddToPositionModal({ open, onOpenChange, profile, existi
|
||||
loading={busyId === p.id}
|
||||
disabled={Boolean(busyId)}
|
||||
>
|
||||
Consider
|
||||
{t('addToPositionModal.consider')}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
import { Mic, Clock, Gauge, Sparkles, Fingerprint, BadgeCheck, Briefcase, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
|
||||
@@ -26,6 +27,7 @@ interface ApplyIdentityCardProps {
|
||||
}
|
||||
|
||||
export default function ApplyIdentityCard({ profile, job, onStart, submitting, onBuildIdentity }: ApplyIdentityCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const ready = profile && profile.full_name;
|
||||
const completeness = Math.min(100, Math.round(profile?.profile_completion || 0));
|
||||
const skills = profile?.skills || [];
|
||||
@@ -39,11 +41,11 @@ export default function ApplyIdentityCard({ profile, job, onStart, submitting, o
|
||||
<div className="px-6 py-5 bg-gradient-to-b from-[#F5F7FB] to-white border-b border-[#E5E7EB]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-semibold text-[#111827]">Apply: {job.title}</h2>
|
||||
<h2 className="text-[18px] font-semibold text-[#111827]">{t('applyIdentityCard.apply')} {job.title}</h2>
|
||||
<p className="text-[12px] text-[#6B7280] mt-0.5">{job.role_category} • {job.location}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold tracking-wide text-[#2563EB] uppercase">
|
||||
<Fingerprint className="w-3.5 h-3.5" /> KROW ID · {krowCode(profile)}
|
||||
<Fingerprint className="w-3.5 h-3.5" /> {t('applyIdentityCard.krowId')} {krowCode(profile)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,32 +63,32 @@ export default function ApplyIdentityCard({ profile, job, onStart, submitting, o
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-[17px] font-bold text-[#111827] truncate">{profile?.full_name || 'Your KROW Identity'}</h3>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#1E40AF]">Verified</span>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#1E40AF]">{t('applyIdentityCard.verified')}</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mt-1">
|
||||
Your KROW Identity is loaded — no form needed. Owliver will run a short AI interview to confirm fit for this role.
|
||||
{t('applyIdentityCard.yourKrowIdentityIsLoadedNo')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Stat label="Experience" value={experienceYears > 0 ? `${experienceYears} yr` : '—'} icon={<Briefcase className="w-3.5 h-3.5" />} />
|
||||
<Stat label="KROW Score" value={krowScore > 0 ? krowScore : '—'} icon={<Gauge className="w-3.5 h-3.5" />} />
|
||||
<Stat label="Profile" value={`${completeness}%`} icon={<Sparkles className="w-3.5 h-3.5" />} />
|
||||
<Stat label={t('applyIdentityCard.experience')} value={experienceYears > 0 ? `${experienceYears} yr` : '—'} icon={<Briefcase className="w-3.5 h-3.5" />} />
|
||||
<Stat label={t('applyIdentityCard.krowScore')} value={krowScore > 0 ? krowScore : '—'} icon={<Gauge className="w-3.5 h-3.5" />} />
|
||||
<Stat label={t('applyIdentityCard.profile')} value={`${completeness}%`} icon={<Sparkles className="w-3.5 h-3.5" />} />
|
||||
</div>
|
||||
|
||||
{/* Skills + certs */}
|
||||
<div className="space-y-3">
|
||||
{skills.length > 0 && (
|
||||
<Row label="Skills">
|
||||
<Row label={t('applyIdentityCard.skills')}>
|
||||
{skills.slice(0, 8).map((s, i) => (
|
||||
<span key={i} className="px-2.5 py-1 rounded-full bg-[#F0F4F8] text-[#1E56C1] text-[11px] font-medium">{s}</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
{certs.length > 0 && (
|
||||
<Row label="Certifications">
|
||||
<Row label={t('applyIdentityCard.certifications')}>
|
||||
{certs.slice(0, 6).map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#F3F4F6] text-[#374151] text-[11px] font-medium">
|
||||
<BadgeCheck className="w-3 h-3" /> {c}
|
||||
@@ -101,17 +103,17 @@ export default function ApplyIdentityCard({ profile, job, onStart, submitting, o
|
||||
<div className="flex items-start gap-2 p-3 bg-[#FFF7ED] rounded-lg border border-[#FED7AA]">
|
||||
<AlertCircle className="w-4 h-4 text-[#B45309] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">Your KROW Identity is light.</p>
|
||||
<p className="text-[11px] text-[#B45309] mt-0.5">You can still interview now — building your identity improves matches.</p>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">{t('applyIdentityCard.yourKrowIdentityIsLight')}</p>
|
||||
<p className="text-[11px] text-[#B45309] mt-0.5">{t('applyIdentityCard.youCanStillInterviewNowBuilding')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interview meta */}
|
||||
<div className="flex items-center justify-center gap-3 text-[12px] text-[#6B7280]">
|
||||
<span className="inline-flex items-center gap-1.5"><Clock className="w-3.5 h-3.5" /> 3 minutes</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Mic className="w-3.5 h-3.5" /> Voice or text</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Gauge className="w-3.5 h-3.5" /> Instant score</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Clock className="w-3.5 h-3.5" /> {t('applyIdentityCard.3Minutes')}</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Mic className="w-3.5 h-3.5" /> {t('applyIdentityCard.voiceOrText')}</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Gauge className="w-3.5 h-3.5" /> {t('applyIdentityCard.instantScore')}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -132,7 +134,7 @@ export default function ApplyIdentityCard({ profile, job, onStart, submitting, o
|
||||
onClick={onBuildIdentity}
|
||||
className="inline-flex items-center justify-center gap-1.5 border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] text-[13px] font-medium px-4 py-3 rounded-xl transition-colors"
|
||||
>
|
||||
<Fingerprint className="w-4 h-4" /> Build Identity
|
||||
<Fingerprint className="w-4 h-4" /> {t('applyIdentityCard.buildIdentity')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
|
||||
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
hired: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Hired', card: 'bg-white border-[#E5E7EB]' },
|
||||
@@ -88,6 +89,7 @@ interface CandidateCardProps {
|
||||
}
|
||||
|
||||
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }: CandidateCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const position = jobTitle || application.job_title || '';
|
||||
const status = application.status || 'applied';
|
||||
@@ -218,7 +220,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
<span>{application.phone}</span>
|
||||
)}
|
||||
{application.years_experience != null && (
|
||||
<span>{application.years_experience}yr exp</span>
|
||||
<span>{application.years_experience}{t('candidateCard.yrExp')}</span>
|
||||
)}
|
||||
</div>
|
||||
{application.ai_match_label && (
|
||||
@@ -254,7 +256,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
|
||||
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
||||
>
|
||||
View profile
|
||||
{t('candidateCard.viewProfile')}
|
||||
<ArrowRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -269,41 +271,41 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
|
||||
Actions
|
||||
{t('candidateCard.actions')}
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
|
||||
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||
<span className="text-[#374151] text-[13px] font-medium">Message</span>
|
||||
<span className="text-[#374151] text-[13px] font-medium">{t('candidateCard.message')}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669] text-[13px] font-medium">Call</span>
|
||||
<span className="text-[#059669] text-[13px] font-medium">{t('candidateCard.call')}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
|
||||
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
|
||||
<span className="text-[#0838E0] text-[13px] font-medium">{t('candidateCard.setUpInterview')}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onHire && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
|
||||
<span className="text-[#059669] text-[13px] font-medium">{t('candidateCard.hireCandidate')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
|
||||
<span className="text-[#DC2626] text-[13px] font-medium">{t('candidateCard.declineCandidate')}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
|
||||
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
|
||||
<span className="text-[#DC2626] text-[13px] font-medium">{t('candidateCard.deleteCandidate')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { Award, BarChart3, Clock, ScrollText, Calendar, User, Users, MessageSquare, CalendarCheck, Dumbbell, Crown, Briefcase, Target, Shield, Check, AlertTriangle } from 'lucide-react';
|
||||
import InterviewResults from '@/components/krow/InterviewResults';
|
||||
import WorkforceReputation from '@/components/krow/WorkforceReputation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const METRIC_CONFIG = [
|
||||
{ key: 'experience', label: 'Experience', Icon: Award },
|
||||
@@ -37,6 +38,7 @@ interface CandidateExpandedDetailsProps {
|
||||
}
|
||||
|
||||
export default function CandidateExpandedDetails({ application }: CandidateExpandedDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
const breakdown = application.score_breakdown || {};
|
||||
const strengths = application.ai_strengths || [];
|
||||
const gaps = application.ai_gaps || [];
|
||||
@@ -47,7 +49,7 @@ export default function CandidateExpandedDetails({ application }: CandidateExpan
|
||||
if (!hasScoreData && strengths.length === 0 && gaps.length === 0 && !hasInterview) {
|
||||
return (
|
||||
<div className="mt-3 pt-4 border-t border-[#F3F4F6]">
|
||||
<p className="text-[13px] text-[#9CA3AF] text-center py-4">No AI screening results yet. Run AI Screen to see detailed metrics.</p>
|
||||
<p className="text-[13px] text-[#9CA3AF] text-center py-4">{t('candidateExpandedDetails.noAiScreeningResultsYetRun')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +62,7 @@ export default function CandidateExpandedDetails({ application }: CandidateExpan
|
||||
{/* Detailed Match Signals — 15 Score Dimensions */}
|
||||
{Object.keys(breakdown).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">MATCH SIGNALS</h4>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">{t('candidateExpandedDetails.matchSignals')}</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{METRIC_CONFIG.map(({ key, label, Icon }) => {
|
||||
const val = breakdown[key] ?? 90;
|
||||
@@ -81,7 +83,7 @@ export default function CandidateExpandedDetails({ application }: CandidateExpan
|
||||
{/* AI Summary */}
|
||||
{application.ai_summary && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5">AI SUMMARY</h4>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5">{t('candidateExpandedDetails.aiSummary')}</h4>
|
||||
<p className="text-[13px] text-[#475569] leading-relaxed font-normal">{application.ai_summary}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -138,7 +140,7 @@ export default function CandidateExpandedDetails({ application }: CandidateExpan
|
||||
{/* AI Recommendation */}
|
||||
{application.ai_recommendation && (
|
||||
<div className="bg-[#F0FDF4] border border-[#BBF7D0] rounded-xl p-3">
|
||||
<p className="text-[12px] text-[#166534]"><span className="font-bold">RECOMMENDATION:</span> {application.ai_recommendation}</p>
|
||||
<p className="text-[12px] text-[#166534]"><span className="font-bold">{t('candidateExpandedDetails.recommendation')}</span> {application.ai_recommendation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import { Input } from '@/components/ui/input';
|
||||
import { Plus, Trash2, ShieldCheck, Loader2 } from 'lucide-react';
|
||||
import { useCertifications, useCreateCertification, useDeleteCertification } from '@/lib/krowHooks';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function CertificationManager() {
|
||||
const { t } = useTranslation();
|
||||
const { data: certifications = [], isLoading } = useCertifications();
|
||||
const createCert = useCreateCertification();
|
||||
const deleteCert = useDeleteCertification();
|
||||
@@ -39,16 +41,16 @@ export default function CertificationManager() {
|
||||
<DialogTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 bg-white border border-[#E5E7EB] hover:border-[#D1D5DB] text-[#374151] text-[13px] font-medium px-4 h-10 rounded-lg transition-colors">
|
||||
<ShieldCheck className="w-4 h-4 text-[#0838E0]" />
|
||||
<span className="hidden sm:inline">Manage Certifications</span>
|
||||
<span className="sm:hidden">Certs</span>
|
||||
<span className="hidden sm:inline">{t('certificationManager.manageCertifications')}</span>
|
||||
<span className="sm:hidden">{t('certificationManager.certs')}</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Certifications</DialogTitle>
|
||||
<DialogTitle>{t('certificationManager.manageCertifications')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-[13px] text-[#6B7280] -mt-1">
|
||||
These are the certification options applicants can select and upload when applying.
|
||||
{t('certificationManager.theseAreTheCertificationOptionsApplicants')}
|
||||
</p>
|
||||
|
||||
{/* Add new */}
|
||||
@@ -56,7 +58,7 @@ export default function CertificationManager() {
|
||||
<Input
|
||||
value={newCert}
|
||||
onChange={(e) => setNewCert(e.target.value)}
|
||||
placeholder="e.g. OSHA 30"
|
||||
placeholder={t('certificationManager.eGOsha30')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -67,7 +69,7 @@ export default function CertificationManager() {
|
||||
className="bg-[#0838E0] hover:bg-[#062BAF]"
|
||||
>
|
||||
{createCert.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||
Add
|
||||
{t('certificationManager.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +79,7 @@ export default function CertificationManager() {
|
||||
<div key={i} className="h-10 bg-[#F3F4F6] rounded-lg animate-pulse" />
|
||||
))}
|
||||
{!isLoading && certifications.length === 0 && (
|
||||
<p className="text-[13px] text-[#9CA3AF] text-center py-6">No certifications yet. Add your first one above.</p>
|
||||
<p className="text-[13px] text-[#9CA3AF] text-center py-6">{t('certificationManager.noCertificationsYetAddYourFirst')}</p>
|
||||
)}
|
||||
{certifications.map((cert) => (
|
||||
<div key={cert.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-[#F9FAFB] border border-[#E5E7EB]">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JobApplication, Staff } from '@/types/entities';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const TIER_COLORS = { Skilled: '#0838E0', 'Cross-Trained': '#333F48', Beginner: '#D1E0D7' };
|
||||
|
||||
@@ -10,6 +11,7 @@ interface HiringAnalyticsProps {
|
||||
}
|
||||
|
||||
export default function HiringAnalytics({ applications, staff }: HiringAnalyticsProps) {
|
||||
const { t } = useTranslation();
|
||||
const byTier = useMemo(() => {
|
||||
const tiers = ['Skilled', 'Cross-Trained', 'Beginner'];
|
||||
return tiers.map(tier => ({
|
||||
@@ -49,27 +51,27 @@ export default function HiringAnalytics({ applications, staff }: HiringAnalytics
|
||||
|
||||
return (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px] mb-1">Hiring Analytics</h3>
|
||||
<p className="text-[12px] text-[#6B7280] mb-5">{staff.length} total hires · {recentHires} in last 7 days</p>
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px] mb-1">{t('hiringAnalytics.hiringAnalytics')}</h3>
|
||||
<p className="text-[12px] text-[#6B7280] mb-5">{staff.length} {t('hiringAnalytics.totalHires')} {recentHires} {t('hiringAnalytics.inLast7Days')}</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
||||
<div className="text-2xl font-heading font-bold text-[#0838E0]">{hireRate}%</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">HIRE RATE</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">{t('hiringAnalytics.hireRate')}</div>
|
||||
</div>
|
||||
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
||||
<div className="text-2xl font-heading font-bold text-[#333F48]">{avgHireScore || '—'}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">AVG SCORE</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">{t('hiringAnalytics.avgScore')}</div>
|
||||
</div>
|
||||
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
||||
<div className="text-2xl font-heading font-bold text-[#0838E0]">{recentHires}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">LAST 7 DAYS</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">{t('hiringAnalytics.last7Days')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-3">Hires by Tier</p>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-3">{t('hiringAnalytics.hiresByTier')}</p>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={byTier}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
@@ -86,7 +88,7 @@ export default function HiringAnalytics({ applications, staff }: HiringAnalytics
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-3">Hires by Role</p>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-3">{t('hiringAnalytics.hiresByRole')}</p>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={byRole} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AIInterview, JobApplication, Staff } from '@/types/entities';
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ImpactMetricsProps {
|
||||
applications: JobApplication[];
|
||||
@@ -10,6 +11,7 @@ interface ImpactMetricsProps {
|
||||
}
|
||||
|
||||
export default function ImpactMetrics({ applications, interviews, staff: _staff }: ImpactMetricsProps) {
|
||||
const { t } = useTranslation();
|
||||
const hired = applications.filter(a => a.status === 'hired');
|
||||
/* Named for what it is: the applications the AI actually scored. Elsewhere
|
||||
in the product "screened" means the stage (status <> 'applied'), which is a
|
||||
@@ -110,7 +112,7 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-[11px] font-bold text-[#9CA3AF] tracking-wider font-heading">
|
||||
HIRING IMPACT · OUR MISSION
|
||||
{t('impactMetrics.hiringImpactOurMission')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AIInterview, JobApplication } from '@/types/entities';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
|
||||
import { Mic, CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const VERDICT_COLORS = { hire: '#0838E0', maybe: '#F9E547', no: '#333F48' };
|
||||
|
||||
@@ -11,6 +12,7 @@ interface InterviewAnalyticsProps {
|
||||
}
|
||||
|
||||
export default function InterviewAnalytics({ applications, interviews }: InterviewAnalyticsProps) {
|
||||
const { t } = useTranslation();
|
||||
const stats = useMemo(() => {
|
||||
const started = applications.filter(a => a.status === 'interview' || a.interview_id).length;
|
||||
const completed = interviews.length;
|
||||
@@ -70,7 +72,7 @@ export default function InterviewAnalytics({ applications, interviews }: Intervi
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{verdictData.length > 0 && (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[15px] mb-4">Verdict Breakdown</h3>
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[15px] mb-4">{t('interviewAnalytics.verdictBreakdown')}</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={verdictData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={75} label>
|
||||
@@ -87,7 +89,7 @@ export default function InterviewAnalytics({ applications, interviews }: Intervi
|
||||
|
||||
{categoryData.length > 0 && (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[15px] mb-4">Avg Category Scores</h3>
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[15px] mb-4">{t('interviewAnalytics.avgCategoryScores')}</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={categoryData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { JobApplication } from '@/types/entities';
|
||||
import React from 'react';
|
||||
import { useInterviews } from '@/lib/krowHooks';
|
||||
import { MessageSquare, CheckCircle2, AlertTriangle, Star, FileText } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const VERDICT_STYLES = {
|
||||
hire: { bg: 'bg-[#F0FDF4]', text: 'text-[#166534]', label: 'Hire' },
|
||||
@@ -38,6 +39,7 @@ interface InterviewResultsProps {
|
||||
}
|
||||
|
||||
export default function InterviewResults({ application }: InterviewResultsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: interviews = [], isLoading } = useInterviews();
|
||||
const interview = interviews
|
||||
.filter((i) => i.application_id === application.id)
|
||||
@@ -46,7 +48,7 @@ export default function InterviewResults({ application }: InterviewResultsProps)
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-3 pt-4 border-t border-[#F3F4F6]">
|
||||
<p className="text-[12px] text-[#9CA3AF]">Loading interview results...</p>
|
||||
<p className="text-[12px] text-[#9CA3AF]">{t('interviewResults.loadingInterviewResults')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +69,7 @@ export default function InterviewResults({ application }: InterviewResultsProps)
|
||||
<div className="w-7 h-7 rounded-lg bg-[#0838E0] flex items-center justify-center">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<h4 className="text-[12px] font-bold text-[#111827] tracking-wide">AI INTERVIEW RESULTS</h4>
|
||||
<h4 className="text-[12px] font-bold text-[#111827] tracking-wide">{t('interviewResults.aiInterviewResults')}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${verdictStyle.bg} ${verdictStyle.text}`}>
|
||||
@@ -144,7 +146,7 @@ export default function InterviewResults({ application }: InterviewResultsProps)
|
||||
<div className="flex items-start gap-2 p-3 bg-[#FEF3C7] rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-[#92400E] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">Integrity Score: {interview.integrity_score}/100</p>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">{t('interviewResults.integrityScore')} {interview.integrity_score}/100</p>
|
||||
{interview.ai_flags?.length > 0 && (
|
||||
<p className="text-[11px] text-[#92400E] mt-1">{interview.ai_flags.join(', ')}</p>
|
||||
)}
|
||||
@@ -155,7 +157,7 @@ export default function InterviewResults({ application }: InterviewResultsProps)
|
||||
{/* Best Fit Roles */}
|
||||
{interview.best_fit_roles?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5 flex items-center gap-1"><Star className="w-3 h-3" /> BEST FIT ROLES</h4>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5 flex items-center gap-1"><Star className="w-3 h-3" /> {t('interviewResults.bestFitRoles')}</h4>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{interview.best_fit_roles.map((role, i) => (
|
||||
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium border border-[#DBEAFE]">
|
||||
@@ -169,7 +171,7 @@ export default function InterviewResults({ application }: InterviewResultsProps)
|
||||
{/* Q&A Transcript */}
|
||||
{messages.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1"><MessageSquare className="w-3 h-3" /> INTERVIEW TRANSCRIPT</h4>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1"><MessageSquare className="w-3 h-3" /> {t('interviewResults.interviewTranscript')}</h4>
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
@@ -181,7 +183,7 @@ export default function InterviewResults({ application }: InterviewResultsProps)
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
{msg.response_time_seconds != null && msg.response_time_seconds < 8 && (
|
||||
<p className="text-[10px] mt-1 opacity-70 inline-flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Fast response ({msg.response_time_seconds}s)
|
||||
<AlertTriangle className="w-3 h-3" /> {t('interviewResults.fastResponse')}{msg.response_time_seconds}s)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Share2, Mail, Link2, Smartphone, Users } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface InviteApplicantsModalProps {
|
||||
open: boolean;
|
||||
@@ -12,6 +13,7 @@ interface InviteApplicantsModalProps {
|
||||
}
|
||||
|
||||
export default function InviteApplicantsModal({ open, onClose, job }: InviteApplicantsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [emailRecipients, setEmailRecipients] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -69,7 +71,7 @@ export default function InviteApplicantsModal({ open, onClose, job }: InviteAppl
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#0838E0]" />
|
||||
Invite Applicants
|
||||
{t('inviteApplicantsModal.inviteApplicants')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -86,7 +88,7 @@ export default function InviteApplicantsModal({ open, onClose, job }: InviteAppl
|
||||
{/* Shareable Link */}
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2">
|
||||
<Link2 className="w-3.5 h-3.5" /> Invitation Link
|
||||
<Link2 className="w-3.5 h-3.5" /> {t('inviteApplicantsModal.invitationLink')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -110,18 +112,18 @@ export default function InviteApplicantsModal({ open, onClose, job }: InviteAppl
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-[#E5E7EB] text-[13px] font-medium text-[#374151] hover:bg-[#F9FAFB] transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
Share via...
|
||||
{t('inviteApplicantsModal.shareVia')}
|
||||
</button>
|
||||
|
||||
{/* Email Invite */}
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2">
|
||||
<Mail className="w-3.5 h-3.5" /> Email Invitation
|
||||
<Mail className="w-3.5 h-3.5" /> {t('inviteApplicantsModal.emailInvitation')}
|
||||
</label>
|
||||
<textarea
|
||||
value={emailRecipients}
|
||||
onChange={(e) => setEmailRecipients(e.target.value)}
|
||||
placeholder="applicant1@email.com, applicant2@email.com"
|
||||
placeholder={t('inviteApplicantsModal.applicant1EmailComApplicant2EmailCom')}
|
||||
className="w-full min-h-[48px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
|
||||
/>
|
||||
<button
|
||||
@@ -130,7 +132,7 @@ export default function InviteApplicantsModal({ open, onClose, job }: InviteAppl
|
||||
className="w-full mt-2 inline-flex items-center justify-center gap-2 py-2.5 rounded-lg bg-[#0838E0] text-white text-[13px] font-medium hover:bg-[#062BAF] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
Send Email Invitations
|
||||
{t('inviteApplicantsModal.sendEmailInvitations')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -138,13 +140,13 @@ export default function InviteApplicantsModal({ open, onClose, job }: InviteAppl
|
||||
<div className="flex items-start gap-2 p-3 bg-[#EEF3FE] rounded-lg">
|
||||
<Smartphone className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
|
||||
<p className="text-[11px] text-[#374151] leading-relaxed">
|
||||
Applicants open the link on any phone — no app download needed. They see open positions and complete the AI interview right in their mobile browser.
|
||||
{t('inviteApplicantsModal.applicantsOpenTheLinkOnAny')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} className="w-full">Done</Button>
|
||||
<Button variant="outline" onClick={onClose} className="w-full">{t('inviteApplicantsModal.done')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JobApplication } from '@/types/entities';
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const TIERS = [
|
||||
{ label: 'Top Talent', range: '80-100', color: 'bg-[#1E3A8A]', textColor: 'text-[#1E3A8A]' },
|
||||
@@ -21,6 +22,7 @@ interface KROWCandidateLeaderboardProps {
|
||||
}
|
||||
|
||||
export default function KROWCandidateLeaderboard({ applications }: KROWCandidateLeaderboardProps) {
|
||||
const { t } = useTranslation();
|
||||
const screened = applications.filter(a => a.ai_score > 0 || a.status !== 'applied');
|
||||
const counts = TIERS.map((_, i) => screened.filter(a => getTier(a.ai_score || 0) === i).length);
|
||||
const total = counts.reduce((a, b) => a + b, 0) || 1;
|
||||
@@ -28,7 +30,7 @@ export default function KROWCandidateLeaderboard({ applications }: KROWCandidate
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-xl p-6">
|
||||
<h3 className="font-semibold text-[#111827] text-[15px] mb-5">Candidate Pipeline</h3>
|
||||
<h3 className="font-semibold text-[#111827] text-[15px] mb-5">{t('kROWCandidateLeaderboard.candidatePipeline')}</h3>
|
||||
|
||||
{/* Bar chart */}
|
||||
<div className="space-y-3 mb-6">
|
||||
@@ -70,7 +72,7 @@ export default function KROWCandidateLeaderboard({ applications }: KROWCandidate
|
||||
</div>
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<div className="text-center py-8 text-[13px] text-[#9CA3AF]">No screened candidates yet</div>
|
||||
<div className="text-center py-8 text-[13px] text-[#9CA3AF]">{t('kROWCandidateLeaderboard.noScreenedCandidatesYet')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { payLabel } from '@/lib/positionModel';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
active: 'bg-[#0838E0] text-white',
|
||||
@@ -24,6 +25,7 @@ interface KROWJobPostingCardProps {
|
||||
}
|
||||
|
||||
export default function KROWJobPostingCard({ posting, stats }: KROWJobPostingCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const applied = stats?.applied ?? 0;
|
||||
const screened = stats?.screened ?? 0;
|
||||
@@ -84,12 +86,12 @@ export default function KROWJobPostingCard({ posting, stats }: KROWJobPostingCar
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[#F3F4F6]">
|
||||
{posting.ai_generated ? (
|
||||
<span className="px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium">
|
||||
AI Built
|
||||
{t('kROWJobPostingCard.aiBuilt')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-[#9CA3AF]">Manual</span>
|
||||
<span className="text-[11px] text-[#9CA3AF]">{t('kROWJobPostingCard.manual')}</span>
|
||||
)}
|
||||
<span className="text-[12px] text-[#9CA3AF] group-hover:text-[#111827] transition-all">View ›</span>
|
||||
<span className="text-[12px] text-[#9CA3AF] group-hover:text-[#111827] transition-all">{t('kROWJobPostingCard.view')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getTalentScore, getScoreBand, getGrowth } from '@/lib/talentHome';
|
||||
import { ShieldCheck, Smartphone, Image as ImageIcon, QrCode, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
const toFICO = (s) => 300 + Math.round((clamp(s) / 100) * 550);
|
||||
@@ -34,9 +35,10 @@ function download(filename, content, type) {
|
||||
}
|
||||
|
||||
export default function KrowIdPreview() {
|
||||
const { t } = useTranslation();
|
||||
const { data: profile, isLoading } = useWorkerProfile();
|
||||
if (isLoading || !profile) {
|
||||
return <div className="flex justify-center py-10 text-xs text-[#6B7280]">Loading KROW ID…</div>;
|
||||
return <div className="flex justify-center py-10 text-xs text-[#6B7280]">{t('krowIdPreview.loadingKrowId')}</div>;
|
||||
}
|
||||
|
||||
const score = getTalentScore(profile);
|
||||
@@ -81,7 +83,7 @@ export default function KrowIdPreview() {
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-[0.12em] text-[#0838E0]">KROW ID · {shortId}</p>
|
||||
<p className="text-[9px] font-semibold uppercase tracking-[0.12em] text-[#0838E0]">{t('krowIdPreview.krowId')} {shortId}</p>
|
||||
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight leading-tight truncate">{name}</h2>
|
||||
<p className="text-[11px] text-[#6B7280] truncate">{title}</p>
|
||||
</div>
|
||||
@@ -116,18 +118,18 @@ export default function KrowIdPreview() {
|
||||
{/* footer: QR + actions */}
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<div className="rounded-lg bg-white p-1 border border-[#E6ECF5] shadow-sm shrink-0">
|
||||
<img src={qrSrc} alt="KROW Identity QR" width={48} height={48} className="block" />
|
||||
<img src={qrSrc} alt={t('krowIdPreview.krowIdentityQr')} width={48} height={48} className="block" />
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-1.5">
|
||||
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-[#0838E0] text-white text-[10px] font-semibold hover:bg-[#062BAF] transition-colors">
|
||||
<Smartphone className="w-3 h-3" /> Contacts
|
||||
<Smartphone className="w-3 h-3" /> {t('krowIdPreview.contacts')}
|
||||
</button>
|
||||
<button onClick={() => toast('Open My KROW ID page to save the full pass.', { icon: 'ℹ️' })} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors">
|
||||
<ImageIcon className="w-3 h-3 text-[#0838E0]" /> Save
|
||||
<ImageIcon className="w-3 h-3 text-[#0838E0]" /> {t('krowIdPreview.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[9px] text-[#9CA3AF] mt-2 flex items-center gap-1 justify-center"><QrCode className="w-2.5 h-2.5" /> Scan to share your verified KROW Identity</p>
|
||||
<p className="text-[9px] text-[#9CA3AF] mt-2 flex items-center gap-1 justify-center"><QrCode className="w-2.5 h-2.5" /> {t('krowIdPreview.scanToShareYourVerifiedKrow')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Camera, Fingerprint, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// A living identity card that reflects — rather than describes — the resume-less
|
||||
// KROW Identity. It fills in as the worker adds a face, skills, languages, etc.
|
||||
@@ -7,6 +8,7 @@ interface KrowIdentityCardProps {
|
||||
}
|
||||
|
||||
export default function KrowIdentityCard({ form }: KrowIdentityCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const parts = [
|
||||
{ key: 'face', filled: !!form.selfie_url },
|
||||
{ key: 'name', filled: !!form.full_name.trim() },
|
||||
@@ -25,17 +27,17 @@ export default function KrowIdentityCard({ form }: KrowIdentityCardProps) {
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
{form.selfie_url ? (
|
||||
<img src={form.selfie_url} alt="You" className="w-20 h-20 rounded-2xl object-cover ring-4 ring-white/25" />
|
||||
<img src={form.selfie_url} alt={t('krowIdentityCard.you')} className="w-20 h-20 rounded-2xl object-cover ring-4 ring-white/25" />
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-2xl border-2 border-dashed border-white/40 flex flex-col items-center justify-center text-white/70">
|
||||
<Camera className="w-6 h-6" />
|
||||
<span className="text-[9px] mt-0.5">your face</span>
|
||||
<span className="text-[9px] mt-0.5">{t('krowIdentityCard.yourFace')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="inline-flex items-center gap-1.5 text-[10px] font-medium tracking-wide bg-white/15 px-2 py-0.5 rounded-full">
|
||||
<Fingerprint className="w-3 h-3" /> KROW Identity
|
||||
<Fingerprint className="w-3 h-3" /> {t('krowIdentityCard.krowIdentity')}
|
||||
</div>
|
||||
<h1 className="text-[22px] sm:text-[26px] font-bold leading-tight mt-1.5 truncate">
|
||||
{form.full_name.trim() || 'Your name'}
|
||||
@@ -47,15 +49,15 @@ export default function KrowIdentityCard({ form }: KrowIdentityCardProps) {
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 space-y-2.5">
|
||||
<SlotRow label="Skills" items={form.skills} />
|
||||
<SlotRow label="Languages" items={form.languages} />
|
||||
<SlotRow label="Availability" items={form.availability} />
|
||||
<SlotRow label="Certifications" items={form.certifications} />
|
||||
<SlotRow label={t('krowIdentityCard.skills')} items={form.skills} />
|
||||
<SlotRow label={t('krowIdentityCard.languages')} items={form.languages} />
|
||||
<SlotRow label={t('krowIdentityCard.availability')} items={form.availability} />
|
||||
<SlotRow label={t('krowIdentityCard.certifications')} items={form.certifications} />
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4">
|
||||
<div className="flex items-center justify-between text-[11px] mb-1.5">
|
||||
<span className="inline-flex items-center gap-1 opacity-90"><Sparkles className="w-3 h-3 text-[#F9E547]" /> Your identity is {pct}% alive</span>
|
||||
<span className="inline-flex items-center gap-1 opacity-90"><Sparkles className="w-3 h-3 text-[#F9E547]" /> {t('krowIdentityCard.yourIdentityIs')} {pct}{t('krowIdentityCard.alive')}</span>
|
||||
<span className="opacity-70">{done}/{parts.length}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-white/15 overflow-hidden">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LEVEL_LABEL, matchPosition, skillName } from '@/lib/skillGraph';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { usePageSkills } from '@/lib/skills/usePageSkills';
|
||||
import { useApplications, useAssignments } from '@/lib/krowHooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Who is qualified for this position, and who is one module away.
|
||||
@@ -42,6 +43,7 @@ interface RequirementRowProps {
|
||||
}
|
||||
|
||||
function RequirementRow({ line }: RequirementRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = line.met ? Check : line.close ? Minus : X;
|
||||
const tone = line.met ? 'text-[#059669]' : line.close ? 'text-[#D97706]' : 'text-[#DC2626]';
|
||||
|
||||
@@ -50,7 +52,7 @@ function RequirementRow({ line }: RequirementRowProps) {
|
||||
<Icon className={cn('h-3.5 w-3.5 shrink-0', tone)} aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-[#374151]">
|
||||
{line.name} — {line.heldLabel}
|
||||
{!line.met && <span className="text-[#9CA3AF]"> · needs {line.requiredLabel}</span>}
|
||||
{!line.met && <span className="text-[#9CA3AF]"> {t('matchedCandidates.needs')} {line.requiredLabel}</span>}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-[#9CA3AF]">
|
||||
{line.earned}/{line.weight}
|
||||
@@ -71,6 +73,7 @@ interface CandidateRowProps {
|
||||
}
|
||||
|
||||
function CandidateRow({ row, job, onView, onInterview, busy, attaches }: CandidateRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
/* Identity is the candidate record; the worker profile is enrichment that
|
||||
most candidates do not have. Nothing here reads a profile field without
|
||||
@@ -132,7 +135,7 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }: Candida
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 text-[12px] text-[#6B7280]">
|
||||
This position states no requirements that can be checked against this candidate's record.
|
||||
{t('matchedCandidates.thisPositionStatesNoRequirementsThat')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -142,8 +145,8 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }: Candida
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-[#DBEAFE] bg-[#EEF3FE] px-3 py-2">
|
||||
<GraduationCap className="h-3.5 w-3.5 shrink-0 text-[#0838E0]" aria-hidden="true" />
|
||||
<p className="min-w-0 flex-1 text-[12px] text-[#1E3A8A]">
|
||||
<span className="font-semibold">{recommendation.module.title}</span> would take{' '}
|
||||
{candidate.applicant_name.split(' ')[0]} to {recommendation.unlocks} — match{' '}
|
||||
<span className="font-semibold">{recommendation.module.title}</span> {t('matchedCandidates.wouldTake')}{' '}
|
||||
{candidate.applicant_name.split(' ')[0]} to {recommendation.unlocks} {t('matchedCandidates.match')}{' '}
|
||||
<span className="font-semibold tabular-nums">{recommendation.currentScore}% → {recommendation.projectedScore}%</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -166,7 +169,7 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }: Candida
|
||||
onClick={() => onInterview(application)}
|
||||
className="rounded-lg border border-[#E5E7EB] px-3 py-1.5 text-[12px] font-medium text-[#374151] transition-colors hover:border-[#0838E0] hover:text-[#0838E0] disabled:opacity-50"
|
||||
>
|
||||
Move to Interview
|
||||
{t('matchedCandidates.moveToInterview')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -174,7 +177,7 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }: Candida
|
||||
onClick={() => onView(row)}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-[#0838E0] px-3 py-1.5 text-[12px] font-medium text-white transition-colors hover:bg-[#062BAF]"
|
||||
>
|
||||
View Candidate <ArrowRight className="h-3 w-3" />
|
||||
{t('matchedCandidates.viewCandidate')} <ArrowRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,13 +185,13 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }: Candida
|
||||
{open && (
|
||||
<div className="mt-3 rounded-lg bg-[#F9FAFB] p-3">
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-[#9CA3AF]">
|
||||
{job.title} — weighted requirements
|
||||
{job.title} {t('matchedCandidates.weightedRequirements')}
|
||||
</p>
|
||||
<ul className="space-y-1 text-[12px] text-[#374151]">
|
||||
{(match?.lines || []).map((line) => (
|
||||
<li key={line.skillId} className="flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 truncate">
|
||||
{line.name} — {LEVEL_LABEL[line.required] || line.requiredLabel} required, holds {line.heldLabel}
|
||||
{line.name} — {LEVEL_LABEL[line.required] || line.requiredLabel} {t('matchedCandidates.requiredHolds')} {line.heldLabel}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums font-medium">
|
||||
{line.weight != null ? `${line.earned} of ${line.weight}` : line.met ? 'Met' : 'Not met'}
|
||||
@@ -197,13 +200,13 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }: Candida
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 border-t border-[#E5E7EB] pt-2 text-[12px] font-semibold text-[#111827]">
|
||||
Total {match.score}% — {match.band}
|
||||
{t('matchedCandidates.total')} {match.score}% — {match.band}
|
||||
</p>
|
||||
{match.gaps.length > 0 && (
|
||||
<p className="mt-1 text-[12px] text-[#B45309]">{match.gaps[0].note}</p>
|
||||
)}
|
||||
<p className="mt-1 text-[11px] text-[#9CA3AF]">
|
||||
A skill one level below its requirement earns half its weight; two or more levels below earns none.
|
||||
{t('matchedCandidates.aSkillOneLevelBelowIts')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -221,6 +224,7 @@ interface MatchedCandidatesProps {
|
||||
}
|
||||
|
||||
export function MatchedCandidates({ job, profiles = [], courses = [], applications = [], onInterview, busy }: MatchedCandidatesProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const positionSkills = usePageSkills('positions');
|
||||
@@ -277,12 +281,12 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">Matched candidates</h3>
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">{t('matchedCandidates.matchedCandidates')}</h3>
|
||||
<p className="mt-0.5 text-[13px] text-[#6B7280]">
|
||||
{counts.candidates} {counts.candidates === 1 ? 'candidate' : 'candidates'} on file
|
||||
{counts.candidates} {counts.candidates === 1 ? 'candidate' : 'candidates'} {t('matchedCandidates.onFile')}
|
||||
{counts.verified > 0
|
||||
? `, ${counts.verified} scored against verified skills`
|
||||
: ', scored against the requirements this position states'}. Best match first.
|
||||
: ', scored against the requirements this position states'}{t('matchedCandidates.bestMatchFirst')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -305,15 +309,13 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
|
||||
{/* What the position asks for, so the scores below have a stated basis. */}
|
||||
{!hasSkillRequirements && (
|
||||
<p className="mt-4 rounded-lg border border-dashed border-[#E5E7EB] px-3 py-2 text-[12px] text-[#6B7280]">
|
||||
This position defines no verified skill requirements, so candidates are scored against the
|
||||
experience, English level and certifications it states. Add skill requirements by editing
|
||||
the position to score against verified training.
|
||||
{t('matchedCandidates.thisPositionDefinesNoVerifiedSkill')}
|
||||
</p>
|
||||
)}
|
||||
{hasSkillRequirements && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg bg-[#F9FAFB] px-3 py-2">
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-[#0838E0]" aria-hidden="true" />
|
||||
<span className="text-[12px] text-[#6B7280]">Requires</span>
|
||||
<span className="text-[12px] text-[#6B7280]">{t('matchedCandidates.requires')}</span>
|
||||
{(job.skill_requirements as any[]).map((req) => (
|
||||
<span key={req.skill_id} className="rounded-full border border-[#E5E7EB] bg-white px-2 py-0.5 text-[12px] text-[#374151]">
|
||||
{skillName(req.skill_id)} — {LEVEL_LABEL[req.level]}
|
||||
@@ -361,6 +363,7 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
|
||||
* scoring function that cannot drift between them.
|
||||
*/
|
||||
export function YourSkillFit({ job, held, className = '' }) {
|
||||
const { t } = useTranslation();
|
||||
const match = matchPosition(job, held);
|
||||
if (!match) return null;
|
||||
const tone = TONE[match.tone] || TONE.warning;
|
||||
@@ -368,7 +371,7 @@ export function YourSkillFit({ job, held, className = '' }) {
|
||||
return (
|
||||
<div className={cn('rounded-2xl border border-[#E5E7EB] bg-white p-6', className)}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">Your skill fit</h3>
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">{t('matchedCandidates.yourSkillFit')}</h3>
|
||||
<span className={cn('rounded-full border px-2.5 py-1 text-[12px] font-semibold', tone.chip)}>
|
||||
{match.score}% · {match.band}
|
||||
</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Mail, Phone, Send, CheckCircle2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface MessageCandidateModalProps {
|
||||
open: boolean;
|
||||
@@ -13,6 +14,7 @@ interface MessageCandidateModalProps {
|
||||
}
|
||||
|
||||
export default function MessageCandidateModal({ open, onClose, application }: MessageCandidateModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [channel, setChannel] = useState('message');
|
||||
const [message, setMessage] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
@@ -30,7 +32,7 @@ export default function MessageCandidateModal({ open, onClose, application }: Me
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message {application?.applicant_name}</DialogTitle>
|
||||
<DialogTitle>{t('messageCandidateModal.message')} {application?.applicant_name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{sent ? (
|
||||
@@ -38,7 +40,7 @@ export default function MessageCandidateModal({ open, onClose, application }: Me
|
||||
<div className="w-12 h-12 rounded-full bg-[#D1FAE5] flex items-center justify-center mx-auto mb-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-[#059669]" />
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[#111827]">Message sent!</p>
|
||||
<p className="text-[14px] font-medium text-[#111827]">{t('messageCandidateModal.messageSent')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -49,7 +51,7 @@ export default function MessageCandidateModal({ open, onClose, application }: Me
|
||||
channel === 'message' ? 'border-[#0838E0] bg-[#EEF3FE] text-[#0838E0]' : 'border-[#E5E7EB] text-[#6B7280]'
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-4 h-4" /> Message
|
||||
<Mail className="w-4 h-4" /> {t('messageCandidateModal.message')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChannel('call')}
|
||||
@@ -57,14 +59,14 @@ export default function MessageCandidateModal({ open, onClose, application }: Me
|
||||
channel === 'call' ? 'border-[#0838E0] bg-[#EEF3FE] text-[#0838E0]' : 'border-[#E5E7EB] text-[#6B7280]'
|
||||
}`}
|
||||
>
|
||||
<Phone className="w-4 h-4" /> Call
|
||||
<Phone className="w-4 h-4" /> {t('messageCandidateModal.call')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{channel === 'call' ? (
|
||||
<div className="py-6 text-center text-[13px] text-[#6B7280]">
|
||||
<Phone className="w-8 h-8 text-[#0838E0] mx-auto mb-2" />
|
||||
Calling {application?.phone || 'candidate'}...
|
||||
{t('messageCandidateModal.calling')} {application?.phone || 'candidate'}...
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
@@ -76,9 +78,9 @@ export default function MessageCandidateModal({ open, onClose, application }: Me
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="outline" onClick={onClose}>{t('messageCandidateModal.cancel')}</Button>
|
||||
<Button onClick={handleSend} disabled={channel === 'message' && !message.trim()} className="bg-[#0838E0] hover:bg-[#062BAF]">
|
||||
<Send className="w-4 h-4 mr-2" /> Send
|
||||
<Send className="w-4 h-4 mr-2" /> {t('messageCandidateModal.send')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const OWLIVER_URL = '/owliver-avatar.png';
|
||||
|
||||
@@ -8,10 +9,11 @@ interface OwliverAvatarProps {
|
||||
}
|
||||
|
||||
export default function OwliverAvatar({ className = 'w-16 h-16', rounded = 'rounded-full' }: OwliverAvatarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<img
|
||||
src={OWLIVER_URL}
|
||||
alt="Owliver"
|
||||
alt={t('owliverAvatar.owliver')}
|
||||
className={`shrink-0 overflow-hidden object-cover ${rounded} ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import Owliver from '@/pages/Owliver';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const OWLIVER_URL = '/owliver-avatar.png';
|
||||
|
||||
@@ -13,6 +14,7 @@ interface OwliverChatBubbleProps {
|
||||
}
|
||||
|
||||
export default function OwliverChatBubble({ open: externalOpen, onOpenChange, showTrigger = true }: OwliverChatBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = externalOpen !== undefined ? externalOpen : internalOpen;
|
||||
const setOpen = (v) => {
|
||||
@@ -31,17 +33,17 @@ export default function OwliverChatBubble({ open: externalOpen, onOpenChange, sh
|
||||
exit={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Ask Owliver"
|
||||
aria-label={t('owliverChatBubble.askOwliver')}
|
||||
className="group flex items-center gap-3 rounded-full bg-white pl-5 pr-2 py-2 w-[min(80vw,360px)]
|
||||
shadow-[0_10px_30px_rgba(10,57,223,0.22),0_2px_6px_rgba(51,63,72,0.08)]
|
||||
ring-1 ring-black/5 transition-transform duration-300 hover:scale-[1.01]"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-[#0838E0] shrink-0" />
|
||||
<span className="text-[15px] text-[#B5B8D3] font-medium flex-1 truncate text-left group-hover:text-[#0838E0] transition-colors">
|
||||
Build your profile
|
||||
{t('owliverChatBubble.buildYourProfile')}
|
||||
</span>
|
||||
<span className="relative block w-12 h-12 rounded-full overflow-hidden ring-2 ring-[#0838E0]/15 shadow-[0_4px_12px_rgba(10,57,223,0.35)] shrink-0">
|
||||
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
|
||||
<Image src={OWLIVER_URL} alt={t('owliverChatBubble.owliver')} fittingType="fill" className="w-full h-full" />
|
||||
</span>
|
||||
</motion.button>
|
||||
) : open ? (
|
||||
@@ -56,15 +58,15 @@ export default function OwliverChatBubble({ open: externalOpen, onOpenChange, sh
|
||||
>
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 bg-[#0838E0] text-white shrink-0">
|
||||
<span className="block w-8 h-8 rounded-full overflow-hidden ring-2 ring-white/40">
|
||||
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
|
||||
<Image src={OWLIVER_URL} alt={t('owliverChatBubble.owliver')} fittingType="fill" className="w-full h-full" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-bold leading-tight flex items-center gap-1.5">
|
||||
Owliver <Sparkles className="w-3.5 h-3.5 text-[#F9E547]" />
|
||||
{t('owliverChatBubble.owliver')} <Sparkles className="w-3.5 h-3.5 text-[#F9E547]" />
|
||||
</div>
|
||||
<div className="text-[11px] text-white/80 leading-tight">Online · ready to chat</div>
|
||||
<div className="text-[11px] text-white/80 leading-tight">{t('owliverChatBubble.onlineReadyToChat')}</div>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} aria-label="Close chat" className="w-8 h-8 rounded-full hover:bg-white/20 flex items-center justify-center transition-colors">
|
||||
<button onClick={() => setOpen(false)} aria-label={t('owliverChatBubble.closeChat')} className="w-8 h-8 rounded-full hover:bg-white/20 flex items-center justify-center transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JobApplication } from '@/types/entities';
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const STAGES = [
|
||||
{ key: 'applied', label: 'Applied', color: 'bg-[#D1E0D7]', text: 'text-[#333F48]' },
|
||||
@@ -15,6 +16,7 @@ interface PipelineFunnelProps {
|
||||
}
|
||||
|
||||
export default function PipelineFunnel({ applications }: PipelineFunnelProps) {
|
||||
const { t } = useTranslation();
|
||||
const counts = STAGES.map((s) => {
|
||||
if (s.key === 'ai_screened') {
|
||||
return applications.filter(a => ['ai_screened', 'shortlisted', 'interview', 'hired'].includes(a.status)).length;
|
||||
@@ -33,8 +35,8 @@ export default function PipelineFunnel({ applications }: PipelineFunnelProps) {
|
||||
|
||||
return (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px] mb-1">Pipeline Stages</h3>
|
||||
<p className="text-[12px] text-[#6B7280] mb-5">Candidate progression through hiring funnel</p>
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px] mb-1">{t('pipelineFunnel.pipelineStages')}</h3>
|
||||
<p className="text-[12px] text-[#6B7280] mb-5">{t('pipelineFunnel.candidateProgressionThroughHiringFunnel')}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{STAGES.map((stage, i) => {
|
||||
@@ -48,11 +50,11 @@ export default function PipelineFunnel({ applications }: PipelineFunnelProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[#374151]">{stage.label}</span>
|
||||
{i > 0 && stepRate <= 100 && (
|
||||
<span className="text-[10px] text-[#9CA3AF]">→ {stepRate}% from prev</span>
|
||||
<span className="text-[10px] text-[#9CA3AF]">→ {stepRate}{t('pipelineFunnel.fromPrev')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] text-[#9CA3AF]">{convRate}% of total</span>
|
||||
<span className="text-[11px] text-[#9CA3AF]">{convRate}{t('pipelineFunnel.ofTotal')}</span>
|
||||
<span className="text-[15px] font-bold text-[#111827] w-8 text-right">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JobPosting } from '@/types/entities';
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
customRequirementsText, englishLabel, experienceLabel, rateLabel, statedRequirements,
|
||||
} from '@/lib/positionModel';
|
||||
@@ -112,6 +113,7 @@ export function PositionOverview({ position, className = '' }: PositionOverviewP
|
||||
* sit side by side rather than beside a gap where a third would have gone.
|
||||
*/
|
||||
export function PositionRequirements({ position, className = '' }) {
|
||||
const { t } = useTranslation();
|
||||
const fields = statedRequirements(position);
|
||||
const certifications = position.certifications_required || [];
|
||||
|
||||
@@ -131,7 +133,7 @@ export function PositionRequirements({ position, className = '' }) {
|
||||
single values above. Shown only when the position asks for any. */}
|
||||
{certifications.length > 0 && (
|
||||
<div className="rounded-xl border border-border bg-surface p-3.5 sm:col-span-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Required Certifications</p>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{t('positionDetails.requiredCertifications')}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{certifications.map((cert) => (
|
||||
<span
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { Star, BadgeCheck, X, Award } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useUpdateStaff } from '@/lib/krowHooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const SKILL_SUGGESTIONS = [
|
||||
'Punctuality', 'Teamwork', 'Communication', 'Reliability',
|
||||
@@ -19,6 +20,7 @@ interface RateEndorseModalProps {
|
||||
}
|
||||
|
||||
export default function RateEndorseModal({ open, onClose, staff, reviewerName }: RateEndorseModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const update = useUpdateStaff();
|
||||
const [rating, setRating] = useState(staff?.client_rating || 0);
|
||||
const [hover, setHover] = useState(0);
|
||||
@@ -66,12 +68,12 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName }:
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Rate & Endorse {staff.name}</DialogTitle>
|
||||
<DialogTitle>{t('rateEndorseModal.rateEndorse')}orse {staff.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-5 space-y-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">Rate & Endorse</p>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">{t('rateEndorseModal.rateEndorse')}orse</p>
|
||||
<h2 className="text-[18px] font-bold text-[#111827] mt-0.5">{staff.name}</h2>
|
||||
<p className="text-[12px] text-[#6B7280] mt-0.5">{staff.role || 'Staff'} {staff.profile_tier && `· ${staff.profile_tier}`}</p>
|
||||
</div>
|
||||
@@ -82,7 +84,7 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName }:
|
||||
|
||||
{/* Star rating */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">Overall Rating</p>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">{t('rateEndorseModal.overallRating')}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
@@ -104,19 +106,19 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName }:
|
||||
|
||||
{/* Endorsement text */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">Endorsement Note</p>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">{t('rateEndorseModal.endorsementNote')}</p>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Share what made them great to work with..."
|
||||
placeholder={t('rateEndorseModal.shareWhatMadeThemGreatTo')}
|
||||
className="w-full rounded-lg border border-[#E5E7EB] bg-white px-3 py-2 text-[13px] focus:border-[#0838E0] focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Endorsed skills */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">Endorsed Skills</p>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">{t('rateEndorseModal.endorsedSkills')}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SKILL_SUGGESTIONS.map((s) => {
|
||||
const on = skills.includes(s);
|
||||
@@ -144,7 +146,7 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName }:
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg border border-[#E5E7EB] text-[#374151] text-[13px] font-medium hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancel
|
||||
{t('rateEndorseModal.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JobApplication } from '@/types/entities';
|
||||
import React, { useState } from 'react';
|
||||
import { Quote, BadgeCheck, Star, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const COMPANY_COLORS = ['bg-[#0838E0]', 'bg-[#333F48]', 'bg-[#062BAF]'];
|
||||
|
||||
@@ -57,6 +58,7 @@ interface ReferencesEndorsementsProps {
|
||||
}
|
||||
|
||||
export default function ReferencesEndorsements({ application }: ReferencesEndorsementsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(true);
|
||||
const voices = buildVoices(application);
|
||||
/* `ai_strengths` is `jsonb` (typed `unknown`) while `skills` is a real
|
||||
@@ -103,10 +105,10 @@ export default function ReferencesEndorsements({ application }: ReferencesEndors
|
||||
<p className="text-[12px] text-[#374151] mt-2 leading-relaxed">"{v.quote}"</p>
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0838E0] text-[#0838E0] text-[11px] font-semibold hover:bg-[#EEF3FE] transition-colors">
|
||||
Request Reference
|
||||
{t('referencesEndorsements.requestReference')}
|
||||
</button>
|
||||
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0838E0] text-[#0838E0] text-[11px] font-semibold hover:bg-[#EEF3FE] transition-colors">
|
||||
Endorse Skill
|
||||
{t('referencesEndorsements.endorseSkill')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Staff } from '@/types/entities';
|
||||
import React, { useMemo } from 'react';
|
||||
import { TrendingUp, TrendingDown, Clock, Activity } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
@@ -16,6 +17,7 @@ interface RetentionMetricsProps {
|
||||
}
|
||||
|
||||
export default function RetentionMetrics({ staff }: RetentionMetricsProps) {
|
||||
const { t } = useTranslation();
|
||||
const stats = useMemo(() => {
|
||||
const total = staff.length;
|
||||
const active = staff.filter((s) => s.status === 'active').length;
|
||||
@@ -53,28 +55,28 @@ export default function RetentionMetrics({ staff }: RetentionMetricsProps) {
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Metric
|
||||
icon={<TrendingUp className="w-4 h-4" />}
|
||||
label="Retention Rate"
|
||||
label={t('retentionMetrics.retentionRate')}
|
||||
value={stats.retentionRate == null ? '—' : `${stats.retentionRate}%`}
|
||||
tone={stats.retentionRate == null ? 'neutral' : stats.retentionRate >= 70 ? 'good' : stats.retentionRate >= 40 ? 'warn' : 'bad'}
|
||||
sub={`${stats.active} active · ${stats.churned} churned`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
label="Still Active"
|
||||
label={t('retentionMetrics.stillActive')}
|
||||
value={stats.active}
|
||||
tone="good"
|
||||
sub={stats.onboarding > 0 ? `+ ${stats.onboarding} onboarding` : 'On track'}
|
||||
/>
|
||||
<Metric
|
||||
icon={<TrendingDown className="w-4 h-4" />}
|
||||
label="Churned"
|
||||
label={t('retentionMetrics.churned')}
|
||||
value={stats.churned}
|
||||
tone={stats.churned === 0 ? 'good' : 'bad'}
|
||||
sub={`${stats.total} total hires`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Clock className="w-4 h-4" />}
|
||||
label="Avg Tenure"
|
||||
label={t('retentionMetrics.avgTenure')}
|
||||
value={stats.avgTenure > 0 ? `${stats.avgTenure}d` : '—'}
|
||||
tone="neutral"
|
||||
sub={`30d: ${stats.c30 ?? '—'} · 90d: ${stats.c90 ?? '—'}`}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Calendar, CalendarX, Check, Clock, Phone, User, UserX, Video } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useCompleteHumanInterview, useInterviews, useRecordInterviewNotHeld,
|
||||
useScheduleHumanInterview, useUserActivity,
|
||||
@@ -70,6 +71,7 @@ interface ScheduleInterviewModalProps {
|
||||
}
|
||||
|
||||
export default function ScheduleInterviewModal({ open, onClose, application, position }: ScheduleInterviewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: activity = [] } = useUserActivity();
|
||||
const { data: interviews = [] } = useInterviews();
|
||||
|
||||
@@ -161,19 +163,19 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
|
||||
<div className="w-12 h-12 rounded-full bg-[#EEF3FE] flex items-center justify-center mx-auto mb-3">
|
||||
<Check className="w-6 h-6 text-[#0838E0]" />
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[#111827]">Interview already recorded</p>
|
||||
<p className="text-[14px] font-medium text-[#111827]">{t('scheduleInterviewModal.interviewAlreadyRecorded')}</p>
|
||||
<p className="text-[12px] text-[#6B7280] mt-1">
|
||||
{application?.applicant_name} is awaiting a hiring decision.
|
||||
{application?.applicant_name} {t('scheduleInterviewModal.isAwaitingAHiringDecision')}
|
||||
</p>
|
||||
<DialogFooter className="mt-5">
|
||||
<Button variant="outline" onClick={onClose}>Close</Button>
|
||||
<Button variant="outline" onClick={onClose}>{t('scheduleInterviewModal.close')}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : state === 'scheduled' && !recording ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] p-3">
|
||||
<p className="text-[13px] font-medium text-[#111827]">
|
||||
{TYPE_LABEL[booked?.type] || 'Video'} interview booked
|
||||
{TYPE_LABEL[booked?.type] || 'Video'} {t('scheduleInterviewModal.interviewBooked')}
|
||||
</p>
|
||||
<p className="text-[12px] text-[#6B7280] mt-0.5">
|
||||
{booked?.date} at {booked?.time}
|
||||
@@ -184,9 +186,9 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-[#374151]">What happened?</p>
|
||||
<p className="text-[13px] font-medium text-[#374151]">{t('scheduleInterviewModal.whatHappened')}</p>
|
||||
<p className="text-[12px] text-[#6B7280] mt-0.5">
|
||||
Only a completed interview puts this candidate up for a hiring decision.
|
||||
{t('scheduleInterviewModal.onlyACompletedInterviewPutsThis')}
|
||||
</p>
|
||||
<div className="grid gap-2 mt-2.5">
|
||||
<button
|
||||
@@ -194,21 +196,21 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
|
||||
className="flex items-center gap-2.5 p-2.5 rounded-lg border border-[#E5E7EB] hover:border-[#0838E0] hover:bg-[#EEF3FE] text-left transition-colors"
|
||||
>
|
||||
<Check className="w-4 h-4 text-[#059669] shrink-0" />
|
||||
<span className="text-[13px] font-medium text-[#111827]">Interview completed</span>
|
||||
<span className="text-[13px] font-medium text-[#111827]">{t('scheduleInterviewModal.interviewCompleted')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRecording(NO_SHOW)}
|
||||
className="flex items-center gap-2.5 p-2.5 rounded-lg border border-[#E5E7EB] hover:border-[#0838E0] hover:bg-[#F9FAFB] text-left transition-colors"
|
||||
>
|
||||
<UserX className="w-4 h-4 text-[#B45309] shrink-0" />
|
||||
<span className="text-[13px] font-medium text-[#111827]">Candidate did not attend</span>
|
||||
<span className="text-[13px] font-medium text-[#111827]">{t('scheduleInterviewModal.candidateDidNotAttend')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRecording(CANCELLED)}
|
||||
className="flex items-center gap-2.5 p-2.5 rounded-lg border border-[#E5E7EB] hover:border-[#0838E0] hover:bg-[#F9FAFB] text-left transition-colors"
|
||||
>
|
||||
<CalendarX className="w-4 h-4 text-[#6B7280] shrink-0" />
|
||||
<span className="text-[13px] font-medium text-[#111827]">Interview cancelled</span>
|
||||
<span className="text-[13px] font-medium text-[#111827]">{t('scheduleInterviewModal.interviewCancelled')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,17 +218,16 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
|
||||
{error && <p className="text-[12px] text-[#B91C1C]">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Close</Button>
|
||||
<Button variant="outline" onClick={onClose}>{t('scheduleInterviewModal.close')}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : recording ? (
|
||||
<div className="space-y-4">
|
||||
{recording === 'completed' ? (
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Interviewer's assessment (optional)</Label>
|
||||
<Label className="text-[13px] text-[#374151]">{t('scheduleInterviewModal.interviewersAssessmentOptional')}</Label>
|
||||
<p className="text-[12px] text-[#6B7280] mt-0.5">
|
||||
Your own reading, entered by you. It is recorded alongside the interview
|
||||
and never decides anything — hiring stays a separate, deliberate action.
|
||||
{t('scheduleInterviewModal.yourOwnReadingEnteredByYou')}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1.5">
|
||||
{ASSESSMENTS.map((a) => (
|
||||
@@ -253,7 +254,7 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Notes (optional)</Label>
|
||||
<Label className="text-[13px] text-[#374151]">{t('scheduleInterviewModal.notesOptional')}</Label>
|
||||
<textarea
|
||||
value={outcomeNotes}
|
||||
onChange={(e) => setOutcomeNotes(e.target.value)}
|
||||
@@ -266,7 +267,7 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setRecording(null); setError(''); }} disabled={busy}>
|
||||
Back
|
||||
{t('scheduleInterviewModal.back')}
|
||||
</Button>
|
||||
<Button onClick={submitOutcome} disabled={busy} className="bg-[#0838E0] hover:bg-[#062BAF]">
|
||||
{recording === 'completed' ? 'Record interview' : 'Save'}
|
||||
@@ -284,7 +285,7 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Interview Type</Label>
|
||||
<Label className="text-[13px] text-[#374151]">{t('scheduleInterviewModal.interviewType')}</Label>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1.5">
|
||||
{TYPES.map((opt) => (
|
||||
<button
|
||||
@@ -303,21 +304,21 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Date</Label>
|
||||
<Label className="text-[13px] text-[#374151]">{t('scheduleInterviewModal.date')}</Label>
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Time</Label>
|
||||
<Label className="text-[13px] text-[#374151]">{t('scheduleInterviewModal.time')}</Label>
|
||||
<Input type="time" value={time} onChange={(e) => setTime(e.target.value)} className="mt-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Notes (optional)</Label>
|
||||
<Label className="text-[13px] text-[#374151]">{t('scheduleInterviewModal.notesOptional')}</Label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add any prep notes..."
|
||||
placeholder={t('scheduleInterviewModal.addAnyPrepNotes')}
|
||||
className={field}
|
||||
/>
|
||||
</div>
|
||||
@@ -326,15 +327,15 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
|
||||
truth: booking records the booking, and nothing more. */}
|
||||
<p className="text-[12px] text-[#6B7280]">
|
||||
<Calendar className="w-3 h-3 inline-block mr-1 -mt-0.5" />
|
||||
Scheduling records the booking. Come back here afterwards to record what happened.
|
||||
{t('scheduleInterviewModal.schedulingRecordsTheBookingComeBack')}
|
||||
</p>
|
||||
|
||||
{error && <p className="text-[12px] text-[#B91C1C]">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>{t('scheduleInterviewModal.cancel')}</Button>
|
||||
<Button onClick={submitSchedule} disabled={!date || !time || busy} className="bg-[#0838E0] hover:bg-[#062BAF]">
|
||||
<Clock className="w-4 h-4 mr-2" /> Schedule
|
||||
<Clock className="w-4 h-4 mr-2" /> {t('scheduleInterviewModal.schedule')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JobApplication } from '@/types/entities';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const TIERS = [
|
||||
{ label: '0-39', range: [0, 39], color: '#333F48' },
|
||||
@@ -14,6 +15,7 @@ interface ScoreDistributionProps {
|
||||
}
|
||||
|
||||
export default function ScoreDistribution({ applications }: ScoreDistributionProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = useMemo(() => {
|
||||
const scored = applications.filter(a => a.ai_score > 0);
|
||||
return TIERS.map((tier) => ({
|
||||
@@ -37,10 +39,10 @@ export default function ScoreDistribution({ applications }: ScoreDistributionPro
|
||||
return (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">Candidate Scores</h3>
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">{t('scoreDistribution.candidateScores')}</h3>
|
||||
<div className="text-right">
|
||||
<div className="text-[22px] font-heading font-bold gradient-text">{avgScore}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide">AVG SCORE</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide">{t('scoreDistribution.avgScore')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mb-5">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { LEVEL_LABEL, SKILLS, skillById } from '@/lib/skillGraph';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Required skills on a position — the recruiter's half of the matching engine.
|
||||
@@ -25,6 +26,7 @@ interface SkillRequirementsFieldProps {
|
||||
}
|
||||
|
||||
export function SkillRequirementsField({ value = [], onChange }: SkillRequirementsFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const rows = Array.isArray(value) ? value : [];
|
||||
const used = new Set(rows.map((r) => r.skill_id));
|
||||
const available = SKILLS.filter((s) => !used.has(s.id));
|
||||
@@ -46,19 +48,19 @@ export function SkillRequirementsField({ value = [], onChange }: SkillRequiremen
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-[13px] font-medium text-[#374151]">
|
||||
Required Skills
|
||||
<span className="ml-1.5 font-normal text-[#6B7280]">— what a candidate must have verified in KROW Forge</span>
|
||||
{t('skillRequirementsField.requiredSkills')}
|
||||
<span className="ml-1.5 font-normal text-[#6B7280]">{t('skillRequirementsField.whatACandidateMustHaveVerified')}</span>
|
||||
</label>
|
||||
{rows.length > 0 && (
|
||||
<span className={`text-[12px] font-medium ${total === 100 ? 'text-[#059669]' : 'text-[#6B7280]'}`}>
|
||||
{total}% weighted
|
||||
{total}{t('skillRequirementsField.weighted')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="mt-1.5 text-[12px] text-[#6B7280]">
|
||||
No skill requirements yet. Without them this position cannot be matched against verified skills.
|
||||
{t('skillRequirementsField.noSkillRequirementsYetWithoutThem')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2">
|
||||
@@ -88,7 +90,7 @@ export function SkillRequirementsField({ value = [], onChange }: SkillRequiremen
|
||||
<select
|
||||
value={row.level}
|
||||
onChange={(e) => update(i, { level: e.target.value })}
|
||||
aria-label="Required level"
|
||||
aria-label={t('skillRequirementsField.requiredLevel')}
|
||||
className="h-10 min-w-[8rem] rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none"
|
||||
>
|
||||
{levels.map((l) => <option key={l} value={l}>{LEVEL_LABEL[l]}</option>)}
|
||||
@@ -127,7 +129,7 @@ export function SkillRequirementsField({ value = [], onChange }: SkillRequiremen
|
||||
onClick={add}
|
||||
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-[#E5E7EB] px-3 py-2 text-[13px] font-medium text-[#374151] transition-colors hover:border-[#0838E0] hover:text-[#0838E0]"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add required skill
|
||||
<Plus className="h-3.5 w-3.5" /> {t('skillRequirementsField.addRequiredSkill')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Sparkles, Loader2, Briefcase, Star } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import TalentDetailModal from '@/components/krow/TalentDetailModal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PORTRAIT_PHOTOS = [
|
||||
'photo-1500648767731-7d3514ad8b31',
|
||||
@@ -40,6 +41,7 @@ interface SuggestedTalentProps {
|
||||
}
|
||||
|
||||
export default function SuggestedTalent({ job, onContact, onSchedule, onHire }: SuggestedTalentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: profiles = [] } = useWorkerProfiles();
|
||||
const matchTalent = useMatchTalentForJob();
|
||||
const [matches, setMatches] = useState([]);
|
||||
@@ -84,7 +86,7 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire }:
|
||||
<div className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-[#0838E0]" />
|
||||
</div>
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">Suggested Talent</h3>
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">{t('suggestedTalent.suggestedTalent')}</h3>
|
||||
</div>
|
||||
{matches.length > 0 && (
|
||||
<button onClick={runMatch} disabled={matchTalent.isPending} className="text-[12px] font-medium text-[#0838E0] hover:underline disabled:opacity-50">
|
||||
@@ -92,16 +94,16 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire }:
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mb-4">Don't wait for applications — KROW matched these talents from the pool against your job. Tap a profile to see the full KROW Identity.</p>
|
||||
<p className="text-[12px] text-[#6B7280] mb-4">{t('suggestedTalent.dontWaitForApplicationsKrowMatched')}</p>
|
||||
|
||||
{matchTalent.isPending && matches.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10 gap-2 text-[#6B7280]">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-[13px]">Matching talent from the pool…</span>
|
||||
<span className="text-[13px]">{t('suggestedTalent.matchingTalentFromThePool')}</span>
|
||||
</div>
|
||||
) : enriched.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-[13px] text-[#6B7280]">No strong matches yet. Try widening your requirements, or re-run the match.</p>
|
||||
<p className="text-[13px] text-[#6B7280]">{t('suggestedTalent.noStrongMatchesYetTryWidening')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -130,9 +132,9 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire }:
|
||||
<Briefcase className="w-3 h-3" />{p.desired_position || p.current_position}
|
||||
</span>
|
||||
)}
|
||||
{p.experience_years > 0 && <span>{p.experience_years}yr exp</span>}
|
||||
{p.experience_years > 0 && <span>{p.experience_years}{t('suggestedTalent.yrExp')}</span>}
|
||||
<span className="inline-flex items-center gap-1 text-[#F59E0B] font-medium">
|
||||
<Star className="w-3 h-3 fill-[#F59E0B]" />Career {toFICO(p.krow_score || 0)}
|
||||
<Star className="w-3 h-3 fill-[#F59E0B]" />{t('suggestedTalent.career')} {toFICO(p.krow_score || 0)}
|
||||
</span>
|
||||
</div>
|
||||
{m.reasons?.length > 0 && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Image } from '@/components/ui/image';
|
||||
import CareerScoreGauge from '@/components/krow/talent/CareerScoreGauge';
|
||||
import ExperienceEndorsements, { verifiedExperience } from '@/components/krow/talent/ExperienceEndorsements';
|
||||
import EliteTalentScore from '@/components/krow/talent/EliteTalentScore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
|
||||
@@ -83,6 +84,7 @@ interface TalentDetailModalProps {
|
||||
}
|
||||
|
||||
export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }: TalentDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!profile) return null;
|
||||
const badges = profile.earned_badges || [];
|
||||
const capabilities = (profile.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending');
|
||||
@@ -99,7 +101,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[calc(100dvh-2rem)] overflow-y-auto p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{profile.full_name} — KROW Identity</DialogTitle>
|
||||
<DialogTitle>{profile.full_name} {t('talentDetailModal.krowIdentity')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* KROW ID header — clean white, no blue band */}
|
||||
@@ -122,7 +124,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="inline-flex items-center gap-1.5 text-[10px] font-bold tracking-wide text-[#2563EB] uppercase">
|
||||
<Fingerprint className="w-3 h-3" /> KROW ID · {krowCode}
|
||||
<Fingerprint className="w-3 h-3" /> {t('talentDetailModal.krowId')} {krowCode}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<h2 className="text-[22px] font-bold leading-tight text-[#111827] truncate">{profile.full_name}</h2>
|
||||
@@ -130,11 +132,11 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${identityClass}`}>{identityTag}</span>
|
||||
)}
|
||||
{openToWork && (
|
||||
<span className="text-[12px] text-[#6B7280]">· Open to work</span>
|
||||
<span className="text-[12px] text-[#6B7280]">{t('talentDetailModal.openToWork')}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-[#6B7280] mt-1">
|
||||
Career Report · {reportDate}
|
||||
{t('talentDetailModal.careerReport')} {reportDate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,16 +146,16 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
|
||||
{/* Quick stats banner — pill capsules, single line */}
|
||||
<div className="flex flex-nowrap gap-1.5 overflow-x-auto no-scrollbar">
|
||||
{profile.experience_years > 0 && (
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label="Experience" value={`${profile.experience_years} yr`} />
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label={t('talentDetailModal.experience')} value={`${profile.experience_years} yr`} />
|
||||
)}
|
||||
{profile.shifts_completed > 0 && (
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label="Shifts" value={profile.shifts_completed} />
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label={t('talentDetailModal.shifts')} value={profile.shifts_completed} />
|
||||
)}
|
||||
{(profile.client_rating > 0 || profile.supervisor_rating > 0) && (
|
||||
<Stat icon={<Star className="w-3 h-3" />} label="Rating" value={`${((profile.client_rating + profile.supervisor_rating) / 2).toFixed(1)}`} valueClass="text-[#F59E0B]" />
|
||||
<Stat icon={<Star className="w-3 h-3" />} label={t('talentDetailModal.rating')} value={`${((profile.client_rating + profile.supervisor_rating) / 2).toFixed(1)}`} valueClass="text-[#F59E0B]" />
|
||||
)}
|
||||
{profile.desired_position && (
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label="Role" value={profile.desired_position} />
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label={t('talentDetailModal.role')} value={profile.desired_position} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -162,7 +164,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
|
||||
<div className="rounded-xl border border-[#DBEAFE] bg-[#EEF3FE] p-4">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Sparkles className="w-4 h-4 text-[#0838E0]" />
|
||||
<span className="text-[13px] font-bold text-[#0838E0]">Match Score: {matchScore}/100</span>
|
||||
<span className="text-[13px] font-bold text-[#0838E0]">{t('talentDetailModal.matchScore')} {matchScore}/100</span>
|
||||
</div>
|
||||
{matchReasons?.length > 0 && (
|
||||
<ul className="text-[12px] text-[#374151] list-disc list-inside space-y-0.5">
|
||||
@@ -214,7 +216,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
|
||||
|
||||
{/* Endorsements / Verified Capabilities */}
|
||||
{capabilities.length > 0 && (
|
||||
<Section label="VERIFIED CAPABILITIES (ENDORSEMENTS)">
|
||||
<Section label={t('talentDetailModal.verifiedCapabilitiesEndorsements')}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.map((c, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] ${c.status === 'verified' ? 'bg-[#F0F4F8] text-[#1E56C1]' : 'bg-[#F3F4F6] text-[#6B7280]'}`}>
|
||||
@@ -229,7 +231,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
|
||||
|
||||
{/* Consolidated Experience & Endorsements — jobs + client endorsements in one list */}
|
||||
{verifiedExperience(profile).length > 0 && (
|
||||
<Section label="EXPERIENCE & ENDORSEMENTS">
|
||||
<Section label={t('talentDetailModal.experienceEndorsements')}>
|
||||
<ExperienceEndorsements profile={profile} />
|
||||
</Section>
|
||||
)}
|
||||
@@ -255,7 +257,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
|
||||
<MessageSquare className="w-3.5 h-3.5 text-[#0838E0]" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">COMMUNICATION STYLE</p>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">{t('talentDetailModal.communicationStyle')}</p>
|
||||
<p className="text-[12px] text-[#374151] leading-relaxed">{profile.communication_style}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,17 +281,17 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{onContact && (
|
||||
<button onClick={() => onContact(profile)} className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
<Mail className="w-4 h-4" /> Contact
|
||||
<Mail className="w-4 h-4" /> {t('talentDetailModal.contact')}
|
||||
</button>
|
||||
)}
|
||||
{onSchedule && (
|
||||
<button onClick={() => onSchedule(profile)} className="inline-flex items-center gap-1.5 border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
<Calendar className="w-4 h-4" /> Schedule Interview
|
||||
<Calendar className="w-4 h-4" /> {t('talentDetailModal.scheduleInterview')}
|
||||
</button>
|
||||
)}
|
||||
{onHire && (
|
||||
<button onClick={() => onHire(profile)} className="inline-flex items-center gap-1.5 border border-[#16A34A] text-[#16A34A] hover:bg-[#16A34A] hover:text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
<UserCheck className="w-4 h-4" /> Quick Hire
|
||||
<UserCheck className="w-4 h-4" /> {t('talentDetailModal.quickHire')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Mail, Briefcase, MapPin, Star, BadgeCheck, Award, ChevronDown } from 'l
|
||||
import { Image } from '@/components/ui/image';
|
||||
import EmployerInsights from '@/components/krow/talent/EmployerInsights';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PORTRAIT_PHOTOS = [
|
||||
'photo-1500648767731-7d3514ad8b31',
|
||||
@@ -32,6 +33,7 @@ interface CareerGaugeProps {
|
||||
}
|
||||
|
||||
function CareerGauge({ fico }: CareerGaugeProps) {
|
||||
const { t } = useTranslation();
|
||||
const MIN = 300;
|
||||
const MAX = 850;
|
||||
const value = Math.max(MIN, Math.min(MAX, fico || MIN));
|
||||
@@ -45,7 +47,7 @@ function CareerGauge({ fico }: CareerGaugeProps) {
|
||||
const color = value > MIN ? '#0838E0' : '#CBD5E1';
|
||||
return (
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<span className="text-[8px] font-semibold text-[#8A94A4] tracking-[0.12em] mb-0.5">CAREER SCORE</span>
|
||||
<span className="text-[8px] font-semibold text-[#8A94A4] tracking-[0.12em] mb-0.5">{t('talentPoolCard.careerScore')}</span>
|
||||
<svg width="110" height="64" viewBox="0 0 110 64" className="overflow-visible">
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`} fill="none" stroke="#E1E4E8" strokeWidth="8" strokeLinecap="round" />
|
||||
{value > MIN && (
|
||||
@@ -75,6 +77,7 @@ interface TalentPoolCardProps {
|
||||
}
|
||||
|
||||
export default function TalentPoolCard({ profile, onContact, onView }: TalentPoolCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const score = profile.krow_score || 0;
|
||||
@@ -149,7 +152,7 @@ export default function TalentPoolCard({ profile, onContact, onView }: TalentPoo
|
||||
</span>
|
||||
)}
|
||||
{profile.experience_years > 0 && (
|
||||
<span>{profile.experience_years}yr exp</span>
|
||||
<span>{profile.experience_years}{t('talentPoolCard.yrExp')}</span>
|
||||
)}
|
||||
{profile.location && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -242,7 +245,7 @@ export default function TalentPoolCard({ profile, onContact, onView }: TalentPoo
|
||||
className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
Contact
|
||||
{t('talentPoolCard.contact')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Lock, ShieldCheck, Zap, Clock, ArrowRight } from 'lucide-react';
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const DIFF_STYLES = {
|
||||
beginner: { label: 'Beginner', bg: 'bg-[#F0FDF4]', text: 'text-[#16A34A]', border: 'border-[#DCFCE7]' },
|
||||
@@ -19,6 +20,7 @@ interface UniversityCourseCardProps {
|
||||
}
|
||||
|
||||
export default function UniversityCourseCard({ course, completed = false, profile }: UniversityCourseCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const lock = isUnlocked(course, profile);
|
||||
const diff = DIFF_STYLES[course.difficulty] || DIFF_STYLES.beginner;
|
||||
const proofSkill = course.proof_skill || course.category || 'Workforce Skill';
|
||||
@@ -41,11 +43,11 @@ export default function UniversityCourseCard({ course, completed = false, profil
|
||||
<div className="shrink-0">
|
||||
{completed ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#16A34A] bg-[#F0FDF4] px-2 py-0.5 rounded-full border border-[#DCFCE7]">
|
||||
<ShieldCheck className="w-3 h-3" /> Verified
|
||||
<ShieldCheck className="w-3 h-3" /> {t('universityCourseCard.verified')}
|
||||
</span>
|
||||
) : !lock.unlocked ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#64748B] bg-[#F8FAFC] px-2 py-0.5 rounded-full border border-[#E2E8F0]">
|
||||
<Lock className="w-3 h-3 text-[#94A3B8]" /> Locked
|
||||
<Lock className="w-3 h-3 text-[#94A3B8]" /> {t('universityCourseCard.locked')}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`inline-flex items-center text-[10px] font-bold px-2 py-0.5 rounded-full border ${diff.bg} ${diff.text} ${diff.border}`}>
|
||||
@@ -67,7 +69,7 @@ export default function UniversityCourseCard({ course, completed = false, profil
|
||||
{/* Requirements / Locked Reason */}
|
||||
{!lock.unlocked && lock.reasons?.length > 0 && (
|
||||
<div className="mt-2.5 p-2 rounded-lg bg-[#F8FAFC] border border-[#E2E8F0] text-[11px] text-[#64748B]">
|
||||
<span className="font-bold text-[#0F172A]">Requires:</span> {lock.reasons[0]}
|
||||
<span className="font-bold text-[#0F172A]">{t('universityCourseCard.requires')}</span> {lock.reasons[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
levelLabel, matchingPositions, profileForEmail,
|
||||
} from '@/lib/skillGraph';
|
||||
import { skillStatesForPage } from '@/lib/skills/pageSkills';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* What a person can actually do, according to the training they completed.
|
||||
@@ -59,11 +60,12 @@ interface SkillPathProps {
|
||||
}
|
||||
|
||||
function SkillPath({ state }: SkillPathProps) {
|
||||
const { t } = useTranslation();
|
||||
const reduced = useReducedMotion();
|
||||
const held = state.verifiedLabel;
|
||||
|
||||
if (!state.nextLevel) {
|
||||
return <p className="mt-1.5 text-caption text-ink-3">{held} · top level on this pathway</p>;
|
||||
return <p className="mt-1.5 text-caption text-ink-3">{held} {t('verifiedSkills.topLevelOnThisPathway')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -123,6 +125,7 @@ export function VerifiedSkills({
|
||||
onOpenTraining = null,
|
||||
className = '',
|
||||
}: VerifiedSkillsProps) {
|
||||
const { t } = useTranslation();
|
||||
/* Accepts a profile or the email of one, so a candidate record — which is an
|
||||
application, not a worker profile — can be matched to the person behind it. */
|
||||
const worker = profile || profileForEmail(profiles, email);
|
||||
@@ -199,7 +202,7 @@ export function VerifiedSkills({
|
||||
return (
|
||||
<section className={className}>
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No worker profile is linked to this person, so there is no verified training to show.
|
||||
{t('verifiedSkills.noWorkerProfileIsLinkedTo')}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
@@ -248,7 +251,7 @@ export function VerifiedSkills({
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="mb-2 font-heading text-caption font-bold uppercase tracking-wide text-ink-4">
|
||||
Recently earned
|
||||
{t('verifiedSkills.recentlyEarned')}
|
||||
</h4>
|
||||
{recent.length ? (
|
||||
<ul className="space-y-1.5">
|
||||
@@ -260,13 +263,13 @@ export function VerifiedSkills({
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">No training completed yet.</p>
|
||||
<p className="text-body-sm text-ink-3">{t('verifiedSkills.noTrainingCompletedYet')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 font-heading text-caption font-bold uppercase tracking-wide text-ink-4">
|
||||
Next recommended training
|
||||
{t('verifiedSkills.nextRecommendedTraining')}
|
||||
</h4>
|
||||
{nextUp ? (
|
||||
<div className="rounded-xl border border-krow-blue/30 bg-krow-blue-tint/40 p-3">
|
||||
@@ -275,8 +278,8 @@ export function VerifiedSkills({
|
||||
{nextUp.module.title}
|
||||
</p>
|
||||
<p className="mt-0.5 text-caption text-ink-2">
|
||||
Unlocks {nextUp.state.name} — {levelLabel(nextUp.module.target_level)}
|
||||
{nextUp.opens && <> · would improve the match for {nextUp.opens.title}</>}
|
||||
{t('verifiedSkills.unlocks')} {nextUp.state.name} — {levelLabel(nextUp.module.target_level)}
|
||||
{nextUp.opens && <> {t('verifiedSkills.wouldImproveTheMatchFor')} {nextUp.opens.title}</>}
|
||||
</p>
|
||||
{onOpenTraining && (
|
||||
<button
|
||||
@@ -284,12 +287,12 @@ export function VerifiedSkills({
|
||||
onClick={() => onOpenTraining(nextUp.module, worker)}
|
||||
className="mt-2 inline-flex items-center gap-1 text-caption font-semibold text-krow-blue hover:underline"
|
||||
>
|
||||
Start training <ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
{t('verifiedSkills.startTraining')} <ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">Nothing outstanding — every ladder is complete.</p>
|
||||
<p className="text-body-sm text-ink-3">{t('verifiedSkills.nothingOutstandingEveryLadderIsComplete')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import ReferencesEndorsements from '@/components/krow/ReferencesEndorsements';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const FICO_MIN = 300;
|
||||
const FICO_MAX = 850;
|
||||
@@ -50,6 +51,7 @@ interface CareerScorePrimaryProps {
|
||||
}
|
||||
|
||||
function CareerScorePrimary({ application }: CareerScorePrimaryProps) {
|
||||
const { t } = useTranslation();
|
||||
const score = careerScoreFromApp(application);
|
||||
const band = getScoreBand(Math.round(application?.ai_score || 0));
|
||||
|
||||
@@ -94,7 +96,7 @@ function CareerScorePrimary({ application }: CareerScorePrimaryProps) {
|
||||
aria-label={`Career score ${score} of a ${FICO_MIN} to ${FICO_MAX} range. Level: ${band.label}.`}
|
||||
>
|
||||
<span className="text-[9px] font-semibold uppercase leading-none tracking-[0.08em] text-ink-4">
|
||||
Career score
|
||||
{t('workforceReputation.careerScore')}
|
||||
</span>
|
||||
<span className="mt-1.5 font-heading text-[32px] font-bold leading-none tabular-nums text-krow-blue">
|
||||
{score}
|
||||
@@ -122,7 +124,7 @@ function CareerScorePrimary({ application }: CareerScorePrimaryProps) {
|
||||
<dl className="grid gap-x-6 gap-y-4 min-[420px]:grid-cols-2 sm:grid-cols-1 lg:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-[0.06em] text-ink-4">
|
||||
Potential market value
|
||||
{t('workforceReputation.potentialMarketValue')}
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-body-lg font-bold text-ink-1">
|
||||
{marketRangeFromApp(application)}
|
||||
@@ -130,7 +132,7 @@ function CareerScorePrimary({ application }: CareerScorePrimaryProps) {
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-[0.06em] text-ink-4">
|
||||
Next promotion
|
||||
{t('workforceReputation.nextPromotion')}
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-body font-semibold text-ink-1">
|
||||
{nextTierFromApp(application)}
|
||||
@@ -209,6 +211,7 @@ interface WorkforceReputationProps {
|
||||
}
|
||||
|
||||
export default function WorkforceReputation({ application }: WorkforceReputationProps) {
|
||||
const { t } = useTranslation();
|
||||
/* `score_breakdown` is a `jsonb` column, typed `unknown` by the registry.
|
||||
This panel reads four named dimensions out of it; what else it holds is
|
||||
still unstated. */
|
||||
@@ -233,7 +236,7 @@ export default function WorkforceReputation({ application }: WorkforceReputation
|
||||
<Shield className="h-4 w-4 text-white" strokeWidth={2.5} />
|
||||
</div>
|
||||
<h4 className="flex items-center gap-1.5 text-[12px] font-bold tracking-wide text-ink-1">
|
||||
WORKFORCE REPUTATION
|
||||
{t('workforceReputation.workforceReputation')}
|
||||
<TrendingUp className="h-3.5 w-3.5 text-krow-blue" />
|
||||
</h4>
|
||||
</div>
|
||||
@@ -243,12 +246,12 @@ export default function WorkforceReputation({ application }: WorkforceReputation
|
||||
{/* Layer 3 — supporting evidence. A hairline is enough to separate it from
|
||||
the score; another container would make it a second card. */}
|
||||
<dl className="mt-4 grid grid-cols-1 gap-x-8 gap-y-2 border-t border-border pt-3 min-[420px]:grid-cols-2 lg:grid-cols-3">
|
||||
<SupportingSignal label="Reliability" value={reliability} suffix="%" />
|
||||
<SupportingSignal label="Attendance" value={attendance} suffix="%" />
|
||||
<SupportingSignal label="Leadership" value={leadership} suffix="%" />
|
||||
<SupportingSignal label="Communication" value={commText} />
|
||||
<SupportingSignal label={t('workforceReputation.reliability')} value={reliability} suffix="%" />
|
||||
<SupportingSignal label={t('workforceReputation.attendance')} value={attendance} suffix="%" />
|
||||
<SupportingSignal label={t('workforceReputation.leadership')} value={leadership} suffix="%" />
|
||||
<SupportingSignal label={t('workforceReputation.communication')} value={commText} />
|
||||
<SupportingSignal
|
||||
label="Client rating"
|
||||
label={t('workforceReputation.clientRating')}
|
||||
value={clientRating > 0 ? clientRating.toFixed(1) : null}
|
||||
icon={Star}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import MediaChallenge from './MediaChallenge';
|
||||
import VideoRecorder from './VideoRecorder';
|
||||
import HazardIdentifier from './HazardIdentifier';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The challenge itself: learn, demonstrate, verify, proof.
|
||||
@@ -84,6 +85,7 @@ interface ChallengeRunnerProps {
|
||||
}
|
||||
|
||||
export default function ChallengeRunner({ course, profile, onPassed }: ChallengeRunnerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(0);
|
||||
const [evaluating, setEvaluating] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
@@ -134,9 +136,9 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
|
||||
{!lock.unlocked ? (
|
||||
<>
|
||||
<p className="flex items-center gap-2 text-body font-semibold text-ink-1">
|
||||
<Lock className="h-4 w-4 text-warning" aria-hidden="true" /> Locked — earn this one
|
||||
<Lock className="h-4 w-4 text-warning" aria-hidden="true" /> {t('challengeRunner.lockedEarnThisOne')}
|
||||
</p>
|
||||
<p className="mt-1 text-body-sm text-ink-3">Real work unlocks learning. Complete these first:</p>
|
||||
<p className="mt-1 text-body-sm text-ink-3">{t('challengeRunner.realWorkUnlocksLearningCompleteThese')}</p>
|
||||
<ul className="mt-2.5 space-y-1.5">
|
||||
{lock.reasons.map((r, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-body-sm text-ink-2">
|
||||
@@ -153,7 +155,7 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-ink-4">Demonstration</p>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-ink-4">{t('challengeRunner.demonstration')}</p>
|
||||
<p className="text-body font-semibold text-ink-1">{TYPE_LABEL[type]}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,7 +163,7 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
|
||||
{ch.prompt || course?.description}
|
||||
</p>
|
||||
<Button shape="rounded" className="mt-4 w-full" onClick={() => setStep(1)}>
|
||||
Show me <ArrowRight aria-hidden="true" />
|
||||
{t('challengeRunner.showMe')} <ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -176,8 +178,8 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
|
||||
<Stepper active={1} />
|
||||
<div className="flex flex-col items-center gap-2 py-6">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-body-sm font-medium text-ink-1">Owliver is evaluating your proof…</p>
|
||||
<p className="text-caption text-ink-3">Measuring whether you can actually perform.</p>
|
||||
<p className="text-body-sm font-medium text-ink-1">{t('challengeRunner.owliverIsEvaluatingYourProof')}</p>
|
||||
<p className="text-caption text-ink-3">{t('challengeRunner.measuringWhetherYouCanActuallyPerform')}</p>
|
||||
</div>
|
||||
</Frame>
|
||||
);
|
||||
@@ -194,7 +196,7 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
|
||||
<div className="mb-4 flex items-start gap-2.5 rounded-xl border border-border bg-surface-subtle p-3">
|
||||
<OwliverAvatar className="h-7 w-7 shrink-0" rounded="rounded-full" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold text-krow-blue">Owliver</p>
|
||||
<p className="text-[11px] font-semibold text-krow-blue">{t('challengeRunner.owliver')}</p>
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">{ch.prompt || course?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +222,7 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-title text-ink-1">{passed ? 'Verified by Owliver' : 'Needs work'}</p>
|
||||
<p className="text-caption tabular-nums text-ink-3">
|
||||
AI confidence: {Math.round(evidence?.ai_score || 0)}/100
|
||||
{t('challengeRunner.aiConfidence')} {Math.round(evidence?.ai_score || 0)}/100
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,11 +278,11 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
|
||||
|
||||
{passed ? (
|
||||
<Button shape="rounded" className="mt-3 w-full" onClick={() => setStep(3)}>
|
||||
See your proof <ArrowRight aria-hidden="true" />
|
||||
{t('challengeRunner.seeYourProof')} <ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(1)}>
|
||||
Try the demonstration again
|
||||
{t('challengeRunner.tryTheDemonstrationAgain')}
|
||||
</Button>
|
||||
)}
|
||||
</Frame>
|
||||
@@ -296,8 +298,8 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
|
||||
<Award className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body font-semibold text-ink-1">Proof stored</p>
|
||||
<p className="text-caption text-ink-3">Capability: {course.proof_skill || course.title}</p>
|
||||
<p className="text-body font-semibold text-ink-1">{t('challengeRunner.proofStored')}</p>
|
||||
<p className="text-caption text-ink-3">{t('challengeRunner.capability')} {course.proof_skill || course.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -319,11 +321,11 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
|
||||
|
||||
<p className="mt-3 flex items-start gap-2 rounded-xl border border-border bg-krow-blue-tint px-3.5 py-3 text-caption leading-relaxed text-krow-blue">
|
||||
<Eye className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
Employers can now see this proof in your Career Wallet. Every skill is backed by evidence.
|
||||
{t('challengeRunner.employersCanNowSeeThisProof')}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(0)}>
|
||||
Back to start
|
||||
{t('challengeRunner.backToStart')}
|
||||
</Button>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CourseView } from '../types';
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PRESET_TAGS = [
|
||||
'Raw meat next to ready-to-eat food',
|
||||
@@ -21,6 +22,7 @@ interface HazardIdentifierProps {
|
||||
}
|
||||
|
||||
export default function HazardIdentifier({ course, onSubmit, submitting }: HazardIdentifierProps) {
|
||||
const { t } = useTranslation();
|
||||
const imageUrl = course?.challenge?.image_url;
|
||||
const [markers, setMarkers] = useState([]);
|
||||
const [pending, setPending] = useState(null);
|
||||
@@ -45,12 +47,12 @@ export default function HazardIdentifier({ course, onSubmit, submitting }: Hazar
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] text-ink-3 mb-3">Tap anywhere on the kitchen photo to mark a hazard. Label each one, then submit for AI evaluation.</p>
|
||||
<p className="text-[13px] text-ink-3 mb-3">{t('hazardIdentifier.tapAnywhereOnTheKitchenPhoto')}</p>
|
||||
<div className="relative rounded-xl overflow-hidden border border-border mb-3 select-none cursor-crosshair" onClick={addMarker}>
|
||||
{imageUrl ? (
|
||||
<Image src={imageUrl} alt="Kitchen scene" className="w-full max-h-80 object-cover" fittingType="fill" />
|
||||
<Image src={imageUrl} alt={t('hazardIdentifier.kitchenScene')} className="w-full max-h-80 object-cover" fittingType="fill" />
|
||||
) : (
|
||||
<div className="w-full h-64 bg-surface-sunken flex items-center justify-center text-[13px] text-ink-4">No scene image configured</div>
|
||||
<div className="w-full h-64 bg-surface-sunken flex items-center justify-center text-[13px] text-ink-4">{t('hazardIdentifier.noSceneImageConfigured')}</div>
|
||||
)}
|
||||
{markers.map((m, i) => (
|
||||
<div key={i} className="absolute -translate-x-1/2 -translate-y-1/2 group" style={{ left: `${m.x * 100}%`, top: `${m.y * 100}%` }} onClick={(e) => e.stopPropagation()}>
|
||||
@@ -68,21 +70,21 @@ export default function HazardIdentifier({ course, onSubmit, submitting }: Hazar
|
||||
|
||||
{pending && (
|
||||
<div className="rounded-xl border border-krow-blue bg-krow-blue-tint p-3 mb-3">
|
||||
<p className="text-[12px] font-medium text-krow-blue mb-2">Label this hazard:</p>
|
||||
<p className="text-[12px] font-medium text-krow-blue mb-2">{t('hazardIdentifier.labelThisHazard')}</p>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{PRESET_TAGS.map((t) => (
|
||||
<button key={t} onClick={() => confirm(t)} className="text-[11px] px-2.5 py-1 rounded-full bg-white border border-border text-ink-2 hover:border-krow-blue">{t}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input value={customLabel} onChange={(e) => setCustomLabel(e.target.value)} placeholder="Or type your own…" className="flex-1 h-8 px-2.5 text-[12px] rounded-lg border border-border focus:border-krow-blue focus:outline-none" />
|
||||
<button onClick={() => customLabel.trim() && confirm(customLabel.trim())} className="h-8 px-3 rounded-lg bg-krow-blue text-white text-[12px] font-medium flex items-center gap-1"><Plus className="w-3.5 h-3.5" /> Add</button>
|
||||
<input value={customLabel} onChange={(e) => setCustomLabel(e.target.value)} placeholder={t('hazardIdentifier.orTypeYourOwn')} className="flex-1 h-8 px-2.5 text-[12px] rounded-lg border border-border focus:border-krow-blue focus:outline-none" />
|
||||
<button onClick={() => customLabel.trim() && confirm(customLabel.trim())} className="h-8 px-3 rounded-lg bg-krow-blue text-white text-[12px] font-medium flex items-center gap-1"><Plus className="w-3.5 h-3.5" /> {t('hazardIdentifier.add')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => onSubmit(markers)} disabled={markers.length === 0 || submitting || !!pending} className="w-full h-10 rounded-xl bg-krow-navy text-white text-[13px] font-semibold disabled:opacity-40">
|
||||
Submit {markers.length} hazard{markers.length !== 1 ? 's' : ''} for AI evaluation
|
||||
{t('hazardIdentifier.submit')} {markers.length} hazard{markers.length !== 1 ? 's' : ''} {t('hazardIdentifier.forAiEvaluation')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useState } from 'react';
|
||||
import { Loader2, Camera } from 'lucide-react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Photo or video proof — capture/upload, then submit for AI evaluation.
|
||||
interface MediaChallengeProps {
|
||||
@@ -12,6 +13,7 @@ interface MediaChallengeProps {
|
||||
}
|
||||
|
||||
export default function MediaChallenge({ course, onSubmit, submitting }: MediaChallengeProps) {
|
||||
const { t } = useTranslation();
|
||||
const type = course?.challenge?.type || 'photo';
|
||||
const [mediaUrl, setMediaUrl] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -43,7 +45,7 @@ export default function MediaChallenge({ course, onSubmit, submitting }: MediaCh
|
||||
{type === 'video' ? (
|
||||
<video src={mediaUrl} controls className="w-full max-h-64 object-cover" />
|
||||
) : (
|
||||
<Image src={mediaUrl} alt="Your proof" className="w-full max-h-64 object-cover" fittingType="fill" />
|
||||
<Image src={mediaUrl} alt={t('mediaChallenge.yourProof')} className="w-full max-h-64 object-cover" fittingType="fill" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -53,7 +55,7 @@ export default function MediaChallenge({ course, onSubmit, submitting }: MediaCh
|
||||
) : (
|
||||
<>
|
||||
<Camera className="w-6 h-6 text-ink-4 mb-2" />
|
||||
<span className="text-[13px] text-ink-3">Tap to {type === 'video' ? 'record' : 'capture'}</span>
|
||||
<span className="text-[13px] text-ink-3">{t('mediaChallenge.tapTo')} {type === 'video' ? 'record' : 'capture'}</span>
|
||||
</>
|
||||
)}
|
||||
<input type="file" accept={accept} capture={capture} onChange={handleFile} className="hidden" disabled={uploading || submitting} />
|
||||
@@ -61,14 +63,14 @@ export default function MediaChallenge({ course, onSubmit, submitting }: MediaCh
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{mediaUrl && (
|
||||
<button onClick={() => setMediaUrl('')} className="h-10 px-4 rounded-xl border border-border text-[13px] font-medium text-ink-3">Retake</button>
|
||||
<button onClick={() => setMediaUrl('')} className="h-10 px-4 rounded-xl border border-border text-[13px] font-medium text-ink-3">{t('mediaChallenge.retake')}</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSubmit(mediaUrl)}
|
||||
disabled={!mediaUrl || uploading || submitting}
|
||||
className="flex-1 h-10 rounded-xl bg-krow-navy text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
>
|
||||
Submit proof for AI evaluation
|
||||
{t('mediaChallenge.submitProofForAiEvaluation')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Loader2 } from 'lucide-react';
|
||||
import { challengeFollowUp } from '@/lib/provingGround';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// AI roleplay challenge — scenario prompt, worker responds, KROW follows up,
|
||||
// then the worker submits the transcript for AI evaluation.
|
||||
@@ -13,6 +14,7 @@ interface RoleplayChallengeProps {
|
||||
}
|
||||
|
||||
export default function RoleplayChallenge({ course, onSubmit, submitting }: RoleplayChallengeProps) {
|
||||
const { t } = useTranslation();
|
||||
const initial = course?.challenge?.prompt || course?.description || 'Demonstrate the skill.';
|
||||
const [messages, setMessages] = useState([{ role: 'ai', content: initial }]);
|
||||
const [input, setInput] = useState('');
|
||||
@@ -46,8 +48,8 @@ export default function RoleplayChallenge({ course, onSubmit, submitting }: Role
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<OwliverAvatar className="w-8 h-8" rounded="rounded-full" />
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-ink-1">Owliver</p>
|
||||
<p className="text-[10px] text-ink-4">Your AI skills coach</p>
|
||||
<p className="text-[13px] font-semibold text-ink-1">{t('roleplayChallenge.owliver')}</p>
|
||||
<p className="text-[10px] text-ink-4">{t('roleplayChallenge.yourAiSkillsCoach')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 mb-4 max-h-72 overflow-y-auto pr-1">
|
||||
@@ -70,7 +72,7 @@ export default function RoleplayChallenge({ course, onSubmit, submitting }: Role
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
disabled={submitting || busy}
|
||||
placeholder="Type your response…"
|
||||
placeholder={t('roleplayChallenge.typeYourResponse')}
|
||||
className="flex-1 h-9 bg-transparent px-1 text-[13px] focus:outline-none placeholder:text-ink-4"
|
||||
/>
|
||||
<button onClick={send} disabled={busy || submitting} className="w-9 h-9 rounded-full bg-krow-blue text-white flex items-center justify-center disabled:opacity-50 shrink-0">
|
||||
@@ -82,7 +84,7 @@ export default function RoleplayChallenge({ course, onSubmit, submitting }: Role
|
||||
disabled={!hasResponded || submitting || busy}
|
||||
className="mt-3 w-full h-10 rounded-xl bg-krow-navy text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
>
|
||||
Submit proof for AI evaluation
|
||||
{t('roleplayChallenge.submitProofForAiEvaluation')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Loader2, Video, Square, RotateCcw } from 'lucide-react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Real in-browser video recording — "record yourself washing your hands."
|
||||
interface VideoRecorderProps {
|
||||
@@ -9,6 +10,7 @@ interface VideoRecorderProps {
|
||||
}
|
||||
|
||||
export default function VideoRecorder({ onSubmit, submitting }: VideoRecorderProps) {
|
||||
const { t } = useTranslation();
|
||||
const videoRef = useRef(null);
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const chunksRef = useRef([]);
|
||||
@@ -93,13 +95,13 @@ export default function VideoRecorder({ onSubmit, submitting }: VideoRecorderPro
|
||||
</div>
|
||||
{!recordedUrl ? (
|
||||
<button onClick={recording ? stop : start} disabled={uploading || submitting} className={`w-full h-11 rounded-xl font-semibold text-[14px] flex items-center justify-center gap-2 text-white ${recording ? 'bg-destructive' : 'bg-krow-blue'}`}>
|
||||
{recording ? <><Square className="w-4 h-4" /> Stop recording</> : <><Video className="w-4 h-4" /> Record yourself</>}
|
||||
{recording ? <><Square className="w-4 h-4" /> {t('videoRecorder.stopRecording')}</> : <><Video className="w-4 h-4" /> {t('videoRecorder.recordYourself')}</>}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={reset} className="h-11 px-4 rounded-xl border border-border text-[13px] font-medium text-ink-3 flex items-center gap-1.5"><RotateCcw className="w-4 h-4" /> Retake</button>
|
||||
<button onClick={reset} className="h-11 px-4 rounded-xl border border-border text-[13px] font-medium text-ink-3 flex items-center gap-1.5"><RotateCcw className="w-4 h-4" /> {t('videoRecorder.retake')}</button>
|
||||
<button onClick={submit} disabled={uploading || submitting} className="flex-1 h-11 rounded-xl bg-krow-navy text-white text-[13px] font-semibold flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : null} Submit proof for AI evaluation
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : null} {t('videoRecorder.submitProofForAiEvaluation')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ProfileView } from '../types';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const LADDER = ['Beginner', 'Cross-Trained', 'Skilled', 'Lead', 'Manager'];
|
||||
|
||||
@@ -8,12 +9,13 @@ interface CareerGrowthProps {
|
||||
}
|
||||
|
||||
export default function CareerGrowth({ profile }: CareerGrowthProps) {
|
||||
const { t } = useTranslation();
|
||||
const lp = Number(profile.leadership_potential) || 0;
|
||||
const currentIdx = lp >= 80 ? 3 : lp >= 60 ? 2 : lp >= 40 ? 1 : 0;
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A]">Career Growth</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mb-5">People don't just work. They level up.</p>
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A]">{t('careerGrowth.careerGrowth')}</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mb-5">{t('careerGrowth.peopleDontJustWorkTheyLevel')}</p>
|
||||
<div className="flex flex-col">
|
||||
{LADDER.map((step, i) => {
|
||||
const done = i < currentIdx;
|
||||
@@ -29,8 +31,8 @@ export default function CareerGrowth({ profile }: CareerGrowthProps) {
|
||||
</div>
|
||||
<div className={`pb-4 pt-1.5 text-[14px] font-medium ${current ? 'text-[#0838E0]' : done ? 'text-[#0F172A]' : 'text-[#9CA3AF]'}`}>
|
||||
{step}
|
||||
{current && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">You are here</span>}
|
||||
{last && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">Target</span>}
|
||||
{current && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">{t('careerGrowth.youAreHere')}</span>}
|
||||
{last && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">{t('careerGrowth.target')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProfileView } from '../types';
|
||||
import React from 'react';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ITEMS = [
|
||||
'Identity',
|
||||
@@ -18,6 +19,7 @@ interface CareerPassportProps {
|
||||
}
|
||||
|
||||
export default function CareerPassport({ profile }: CareerPassportProps) {
|
||||
const { t } = useTranslation();
|
||||
const certs = (profile.certifications || []).map((c) => c.toLowerCase());
|
||||
const skills = (profile.skills || []).map((s) => s.toLowerCase());
|
||||
const badges = (profile.earned_badges || []).map((b) => (b.name || '').toLowerCase());
|
||||
@@ -51,8 +53,8 @@ export default function CareerPassport({ profile }: CareerPassportProps) {
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Career Passport</h2>
|
||||
<span className="text-[12px] font-semibold text-[#0838E0]">{pct}% Complete</span>
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">{t('careerPassport.careerPassport')}</h2>
|
||||
<span className="text-[12px] font-semibold text-[#0838E0]">{pct}{t('careerPassport.complete')}</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{ITEMS.map((label) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { Info, RefreshCw } from 'lucide-react';
|
||||
import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge';
|
||||
import { getTalentScore, toFICO, getScoreBand } from '@/lib/talentHome';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const FICO_MIN = 300;
|
||||
const FICO_MAX = 850;
|
||||
@@ -13,6 +14,7 @@ interface CareerScoreGaugeProps {
|
||||
}
|
||||
|
||||
export default function CareerScoreGauge({ profile }: CareerScoreGaugeProps) {
|
||||
const { t } = useTranslation();
|
||||
const raw = getTalentScore(profile);
|
||||
const score = toFICO(raw);
|
||||
const band = getScoreBand(raw);
|
||||
@@ -47,7 +49,7 @@ export default function CareerScoreGauge({ profile }: CareerScoreGaugeProps) {
|
||||
className="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end pb-1 pointer-events-none"
|
||||
aria-label={`Career Score: ${score}, Range: ${FICO_MIN}-${FICO_MAX}`}
|
||||
>
|
||||
<span className="text-[9px] uppercase tracking-wider text-[#94A3B8] font-semibold">CAREER SCORE</span>
|
||||
<span className="text-[9px] uppercase tracking-wider text-[#94A3B8] font-semibold">{t('careerScoreGauge.careerScore')}</span>
|
||||
<span className="text-[24px] font-extrabold text-[#0838E0] leading-none mt-0.5 tabular-nums">{score}</span>
|
||||
<span className="text-[9px] text-[#8690A1] font-medium mt-0.5">{FICO_MIN}–{FICO_MAX}</span>
|
||||
</div>
|
||||
@@ -61,7 +63,7 @@ export default function CareerScoreGauge({ profile }: CareerScoreGaugeProps) {
|
||||
|
||||
<div className="inline-flex items-center gap-1.5 mt-2 text-[11px] text-[#7D8590]">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Next update in 31 days
|
||||
{t('careerScoreGauge.nextUpdateIn31Days')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,14 @@ import type { ProfileView } from '../types';
|
||||
import React from 'react';
|
||||
import { Briefcase, Sparkles, Star, Quote } from 'lucide-react';
|
||||
import { esatTag, recentJobs, clientEndorsements } from '@/lib/talentInsights';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EmployerInsightsProps {
|
||||
profile: ProfileView;
|
||||
}
|
||||
|
||||
export default function EmployerInsights({ profile }: EmployerInsightsProps) {
|
||||
const { t } = useTranslation();
|
||||
const esat = esatTag(profile);
|
||||
const jobs = recentJobs(profile);
|
||||
const endorsements = clientEndorsements(profile);
|
||||
@@ -19,7 +21,7 @@ export default function EmployerInsights({ profile }: EmployerInsightsProps) {
|
||||
{/* ESAT + Job history header row */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider flex items-center gap-1.5">
|
||||
<Briefcase className="w-3 h-3" /> JOB HISTORY · 3 YR
|
||||
<Briefcase className="w-3 h-3" /> {t('employerInsights.jobHistory3Yr')}
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#9CA3AF] tracking-wider">
|
||||
<Sparkles className="w-3 h-3" /> ESAT
|
||||
@@ -44,7 +46,7 @@ export default function EmployerInsights({ profile }: EmployerInsightsProps) {
|
||||
{endorsements.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<Quote className="w-3 h-3" /> CLIENT ENDORSEMENTS
|
||||
<Quote className="w-3 h-3" /> {t('employerInsights.clientEndorsements')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{endorsements.map((en, i) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Star, Briefcase, Quote } from 'lucide-react';
|
||||
import { verifiedExperience } from './VerifiedExperienceList';
|
||||
export { verifiedExperience };
|
||||
import { clientEndorsements } from '@/lib/talentInsights';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Props for the local `Stars` helper in this file. Optional throughout:
|
||||
the JavaScript validated none of them and callers omit freely. */
|
||||
@@ -34,6 +35,7 @@ interface ExperienceEndorsementsProps {
|
||||
}
|
||||
|
||||
export default function ExperienceEndorsements({ profile, onReference }: ExperienceEndorsementsProps) {
|
||||
const { t } = useTranslation();
|
||||
const jobs = verifiedExperience(profile);
|
||||
if (jobs.length === 0) return null;
|
||||
|
||||
@@ -62,12 +64,12 @@ export default function ExperienceEndorsements({ profile, onReference }: Experie
|
||||
onClick={() => onReference?.(x)}
|
||||
className="hidden sm:inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-[#0838E0] text-[#0838E0] text-[11px] font-semibold hover:bg-[#EEF3FE] transition-colors"
|
||||
>
|
||||
Request Reference
|
||||
{t('experienceEndorsements.requestReference')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-[#16A34A] font-medium mt-1 truncate">
|
||||
{x.shifts} shifts · {x.supervisor}
|
||||
{x.shifts} {t('experienceEndorsements.shifts')} {x.supervisor}
|
||||
{x.years != null && <span className="text-[#9CA3AF]"> · {x.years} yr</span>}
|
||||
</p>
|
||||
{en && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProfileView } from '../types';
|
||||
import React from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const SEED_NETWORK = ['Luis', 'Chef Antonio'];
|
||||
const SEED_ROLES = ['Supervisor', 'Chef'];
|
||||
@@ -10,6 +11,7 @@ interface ExperienceNetworkProps {
|
||||
}
|
||||
|
||||
export default function ExperienceNetwork({ profile }: ExperienceNetworkProps) {
|
||||
const { t } = useTranslation();
|
||||
const exp = (profile.experience || []).slice(0, 3);
|
||||
const list = exp.length
|
||||
? exp.map((e, i) => ({
|
||||
@@ -27,13 +29,13 @@ export default function ExperienceNetwork({ profile }: ExperienceNetworkProps) {
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Verified Experience & Network</h2>
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">{t('experienceNetwork.verifiedExperienceNetwork')}</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
{list.map((x, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-xl border border-[#E5E7EB] p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] font-semibold text-[#0F172A] truncate">{x.company}</p>
|
||||
<p className="text-[12px] text-[#16A34A] font-medium">{x.shifts} shifts · {x.ref} ({x.role})</p>
|
||||
<p className="text-[12px] text-[#16A34A] font-medium">{x.shifts} {t('experienceNetwork.shifts')} {x.ref} ({x.role})</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="flex">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from 'react-hot-toast';
|
||||
import { Gauge as MuiGauge, gaugeClasses } from '@mui/x-charts/Gauge';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getTalentScore, getScoreBand, getGrowth } from '@/lib/talentHome';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const clamp = (n: unknown, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
|
||||
@@ -58,6 +59,7 @@ interface GaugeProps {
|
||||
}
|
||||
|
||||
function Gauge({ fico }: GaugeProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="relative mx-auto flex items-center justify-center" style={{ width: 140, height: 80 }}>
|
||||
<MuiGauge
|
||||
@@ -86,7 +88,7 @@ function Gauge({ fico }: GaugeProps) {
|
||||
className="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end pb-1 pointer-events-none"
|
||||
aria-label={`Career Score: ${fico}, Range: 300-850`}
|
||||
>
|
||||
<span className="text-[9px] uppercase tracking-wider text-[#94A3B8] font-semibold">CAREER SCORE</span>
|
||||
<span className="text-[9px] uppercase tracking-wider text-[#94A3B8] font-semibold">{t('identityCareerCard.careerScore')}</span>
|
||||
<span className="text-[24px] font-extrabold text-[#0838E0] leading-none mt-0.5 tabular-nums">{fico}</span>
|
||||
<span className="text-[9px] text-[#8690A1] font-medium mt-0.5">300–850</span>
|
||||
</div>
|
||||
@@ -139,6 +141,7 @@ interface IdentityCareerCardProps {
|
||||
}
|
||||
|
||||
export default function IdentityCareerCard({ profile }: IdentityCareerCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const stackRef = useRef(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -204,11 +207,11 @@ export default function IdentityCareerCard({ profile }: IdentityCareerCardProps)
|
||||
<span className="text-[20px] font-bold text-[#0838E0]">{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#0838E0]">KROW ID · {shortId}</p>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#0838E0]">{t('identityCareerCard.krowId')} {shortId}</p>
|
||||
<h2 className="text-[20px] font-bold text-[#0F172A] tracking-tight mt-1">{name}</h2>
|
||||
<p className="text-[12px] text-[#6B7280]">{title}</p>
|
||||
<div className="mt-1.5 inline-flex items-center gap-1 text-[10px] text-[#9CA3AF]">
|
||||
<ShieldCheck className="w-3 h-3" /> Career Credit Report · {updated}
|
||||
<ShieldCheck className="w-3 h-3" /> {t('identityCareerCard.careerCreditReport')} {updated}
|
||||
</div>
|
||||
|
||||
{/* Career Score gauge */}
|
||||
@@ -221,27 +224,27 @@ export default function IdentityCareerCard({ profile }: IdentityCareerCardProps)
|
||||
</span>
|
||||
<div className="inline-flex items-center gap-1.5 mt-2 text-[11px] text-[#7D8590]">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Next update in 31 days
|
||||
{t('identityCareerCard.nextUpdateIn31Days')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score factors */}
|
||||
<div className="mt-4 text-left">
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] mb-2">Score factors</p>
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] mb-2">{t('identityCareerCard.scoreFactors')}</p>
|
||||
<div className="flex flex-wrap gap-1.5 justify-center">
|
||||
<FactorPill label="Reliability" value={clamp(profile.reliability_score)} />
|
||||
<FactorPill label="Attendance" value={clamp(profile.attendance_score ?? 100)} />
|
||||
<FactorPill label="Performance" value={clamp(profile.performance_score)} />
|
||||
<FactorPill label="Leadership" value={clamp(profile.leadership_potential)} />
|
||||
<FactorPill label="AI Interview" value={clamp(profile.ai_interview_score)} />
|
||||
<FactorPill label="XP Earned" value={Number(profile.xp) || 0} />
|
||||
<FactorPill label={t('identityCareerCard.reliability')} value={clamp(profile.reliability_score)} />
|
||||
<FactorPill label={t('identityCareerCard.attendance')} value={clamp(profile.attendance_score ?? 100)} />
|
||||
<FactorPill label={t('identityCareerCard.performance')} value={clamp(profile.performance_score)} />
|
||||
<FactorPill label={t('identityCareerCard.leadership')} value={clamp(profile.leadership_potential)} />
|
||||
<FactorPill label={t('identityCareerCard.aiInterview')} value={clamp(profile.ai_interview_score)} />
|
||||
<FactorPill label={t('identityCareerCard.xpEarned')} value={Number(profile.xp) || 0} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report rows */}
|
||||
<div className="mt-4 text-left rounded-2xl bg-white/60 border border-white/70 p-3 backdrop-blur-sm">
|
||||
<ReportRow icon={ShieldCheck} label="Identity" value={identityOk ? 'Verified' : 'Pending'} tone={identityOk ? 'verified' : 'amber'} />
|
||||
<ReportRow icon={Briefcase} label="Job History" value={`${experience.length} ${experience.length === 1 ? 'role' : 'roles'}`} />
|
||||
<ReportRow icon={ShieldCheck} label={t('identityCareerCard.identity')} value={identityOk ? 'Verified' : 'Pending'} tone={identityOk ? 'verified' : 'amber'} />
|
||||
<ReportRow icon={Briefcase} label={t('identityCareerCard.jobHistory')} value={`${experience.length} ${experience.length === 1 ? 'role' : 'roles'}`} />
|
||||
<ReportRow icon={Sparkles} label="ESAT" value={`${esat}/100`} tone={esat >= 75 ? 'verified' : esat >= 40 ? 'amber' : 'neutral'} />
|
||||
</div>
|
||||
|
||||
@@ -262,23 +265,23 @@ export default function IdentityCareerCard({ profile }: IdentityCareerCardProps)
|
||||
|
||||
{/* Take your KROW ID */}
|
||||
<div className="mt-4 text-left">
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] mb-2 flex items-center gap-1.5"><Download className="w-3 h-3" /> Take your KROW ID</p>
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] mb-2 flex items-center gap-1.5"><Download className="w-3 h-3" /> {t('identityCareerCard.takeYourKrowId')}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-white p-1.5 border border-[#E6ECF5] shadow-sm shrink-0">
|
||||
<img src={qrSrc} alt="KROW Identity QR" width={64} height={64} className="block" />
|
||||
<img src={qrSrc} alt={t('identityCareerCard.krowIdentityQr')} width={64} height={64} className="block" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-[#0838E0] text-white text-[10px] font-semibold hover:bg-[#062BAF] transition-colors"><Wallet className="w-3 h-3" /> Apple</button>
|
||||
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors"><Wallet className="w-3 h-3 text-[#0838E0]" /> Google</button>
|
||||
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-[#0838E0] text-white text-[10px] font-semibold hover:bg-[#062BAF] transition-colors"><Wallet className="w-3 h-3" /> {t('identityCareerCard.apple')}</button>
|
||||
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors"><Wallet className="w-3 h-3 text-[#0838E0]" /> {t('identityCareerCard.google')}</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors"><Smartphone className="w-3 h-3 text-[#0838E0]" /> Contacts</button>
|
||||
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors"><Smartphone className="w-3 h-3 text-[#0838E0]" /> {t('identityCareerCard.contacts')}</button>
|
||||
<button onClick={saveReport} disabled={busy} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors disabled:opacity-60"><ImageIcon className="w-3 h-3 text-[#0838E0]" /> {busy ? '…' : 'Save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-[#9CA3AF] mt-2 flex items-center gap-1 justify-center"><QrCode className="w-3 h-3" /> Scan to share your verified KROW Identity</p>
|
||||
<p className="text-[10px] text-[#9CA3AF] mt-2 flex items-center gap-1 justify-center"><QrCode className="w-3 h-3" /> {t('identityCareerCard.scanToShareYourVerifiedKrow')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CourseView } from '../types';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Clock, ArrowRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const UNLOCKS = ['Google, Adobe, Apple', 'Lead Cook', 'Senior roles'];
|
||||
|
||||
@@ -16,11 +17,12 @@ interface IncreaseMarketValueProps {
|
||||
}
|
||||
|
||||
export default function IncreaseMarketValue({ courses = [] }: IncreaseMarketValueProps) {
|
||||
const { t } = useTranslation();
|
||||
const list = (courses || []).filter((c) => c.status === 'active').slice(0, 3);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Increase Your Market Value</h2>
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">{t('increaseMarketValue.increaseYourMarketValue')}</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{list.map((c, i) => {
|
||||
const { score, pay } = roi(c, i);
|
||||
@@ -35,13 +37,13 @@ export default function IncreaseMarketValue({ courses = [] }: IncreaseMarketValu
|
||||
<ArrowRight className="w-4 h-4 text-[#94A3B8]" />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[12px]">
|
||||
<span className="text-[#16A34A] font-semibold">+{score} Score</span>
|
||||
<span className="text-[#16A34A] font-semibold">+{score} {t('increaseMarketValue.score')}</span>
|
||||
<span className="text-[#0838E0] font-semibold">+${pay.toFixed(2)}/hr</span>
|
||||
<span className="inline-flex items-center gap-1 text-[#6B7280]">
|
||||
<Clock className="w-3 h-3" /> {c.estimated_minutes || 15} min
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[#94A3B8]">Unlocks {UNLOCKS[i] || 'new roles'}</p>
|
||||
<p className="mt-1 text-[11px] text-[#94A3B8]">{t('increaseMarketValue.unlocks')} {UNLOCKS[i] || 'new roles'}</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user