Update Owliver language and suggestions

This commit is contained in:
2026-10-07 19:15:44 +05:30
parent 8ac4e1ee88
commit 81e5979f8f
156 changed files with 7330 additions and 1755 deletions

68
package-lock.json generated
View File

@@ -31,10 +31,12 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.16.4", "framer-motion": "^11.16.4",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"i18next": "^26.4.2",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.16",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"recharts": "^3.10.1", "recharts": "^3.10.1",
@@ -6315,6 +6317,15 @@
"react-is": "^16.7.0" "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": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -6366,6 +6377,34 @@
"node": ">= 14" "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": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -8701,6 +8740,33 @@
"react-dom": ">=16" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -10073,7 +10139,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

View File

@@ -39,10 +39,12 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.16.4", "framer-motion": "^11.16.4",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"i18next": "^26.4.2",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.16",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"recharts": "^3.10.1", "recharts": "^3.10.1",

58
scripts/i18n-audit.mjs Normal file
View 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
View 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
View 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');

View File

@@ -1513,6 +1513,14 @@ record('the Owliver skill offers its chip, and draws no card',
*/ */
console.log('\n── Owliver suggestions from the API ──'); 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'); const serverSuggestions = await server.ssrLoadModule('/src/lib/skills/serverSuggestions.js');
/* ── The request ─────────────────────────────────────────────────────────── */ /* ── 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)`); `${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', 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)), && /shipped\s*\?/.test(block)),
'the label is chosen by `shipped`, not by name'); '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. */ the confirmation names "Archived" while no such tab is on screen. */
record('...and Archived is offered even when it is empty', record('...and Archived is offered even when it is empty',
!/\{archivedCount > 0 && \(/.test(agentsListSource) !/\{archivedCount > 0 && \(/.test(agentsListSource)
&& /Archived <span[^>]*>\(\{archivedCount\}\)/.test(agentsListSource), && /workspaceAgents\.archived'\)\} <span[^>]*>\(\{archivedCount\}\)/.test(agentsListSource),
'the tab is unconditional and shows its count'); 'the tab is unconditional and shows its count');
record('...and reverting a built-in agent is not worded as a removal', 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', record('the header still states version, status and customization',
/v\{agent\.version\}/.test(detailSource) /v\{agent\.version\}/.test(detailSource)
&& /\{agent\.status\}/.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), && /Publish update/.test(detailSource),
'v · status · Customized · Publish update'); 'v · status · Customized · Publish update');
@@ -9082,7 +9094,14 @@ console.log('\n── Candidates vs Talent Pool ──');
} }
html = html.slice(0, at) + html.slice(i); 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); })().pass);
} }
} }
@@ -9323,6 +9342,106 @@ console.log('\n── Candidates vs Talent Pool ──');
return { pass: ids.length > 0, detail: ids.join(', ') }; return { pass: ids.length > 0, detail: ids.join(', ') };
})().pass); })().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);
}
} }
} }

View File

@@ -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 } from "@/components/ui/toaster"
import { Toaster as HotToaster } from 'react-hot-toast'; import { Toaster as HotToaster } from 'react-hot-toast';
import { QueryClientProvider } from '@tanstack/react-query' import { QueryClientProvider } from '@tanstack/react-query'

View File

@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
const UserNotRegisteredError = () => { const UserNotRegisteredError = () => {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-white to-slate-50"> <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"> <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" /> <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> </svg>
</div> </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"> <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> </p>
<div className="p-4 bg-slate-50 rounded-md text-sm text-slate-600"> <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"> <ul className="list-disc list-inside mt-2 space-y-1">
<li>Verify you are logged in with the correct account</li> <li>{t('userNotRegisteredError.verifyYouAreLoggedInWith')}</li>
<li>Contact the app administrator for access</li> <li>{t('userNotRegisteredError.contactTheAppAdministratorForAccess')}</li>
<li>Try logging out and back in again</li> <li>{t('userNotRegisteredError.tryLoggingOutAndBackIn')}</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** /**
* Shared page furniture for the Admin product. * Shared page furniture for the Admin product.
@@ -28,6 +29,7 @@ interface AdminPageProps {
} }
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }: AdminPageProps) { export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }: AdminPageProps) {
const { t } = useTranslation();
return ( return (
<div className={cn('space-y-6', className)}> <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"> <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>} {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="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" /> <span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
Live System {t('shell.liveSystem')}
</span> </span>
</div> </div>
{subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>} {subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>}

View File

@@ -3,6 +3,7 @@ import { ChevronRight, X } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { agentIconFor } from './icons'; import { agentIconFor } from './icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar'; import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useTranslation } from 'react-i18next';
/** /**
* The Agent Configure workspace, as parts. * The Agent Configure workspace, as parts.
@@ -100,11 +101,12 @@ export function Collapse({ open, children, className }: any) {
* configuration workspace. * configuration workspace.
*/ */
export function Rail({ sections, activeId, onSelect }: any) { export function Rail({ sections, activeId, onSelect }: any) {
const { t } = useTranslation();
const activeSection = sections.find((s) => s.id === activeId); const activeSection = sections.find((s) => s.id === activeId);
return ( return (
<nav <nav
aria-label="Agent configuration" aria-label={t('agentCanvas.agentConfiguration')}
className="flex flex-wrap items-center justify-between gap-2.5" className="flex flex-wrap items-center justify-between gap-2.5"
> >
{/* Section anchor pills */} {/* Section anchor pills */}
@@ -151,7 +153,7 @@ export function Rail({ sections, activeId, onSelect }: any) {
{/* Sub-item quick jump leaves */} {/* Sub-item quick jump leaves */}
{activeSection?.items?.length > 0 && ( {activeSection?.items?.length > 0 && (
<div className="hidden items-center gap-1.5 md:flex"> <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) => ( {activeSection.items.map((item) => (
<button <button
key={item.id} key={item.id}
@@ -478,11 +480,12 @@ export function IconPicker({ icons, value, onChange }: any) {
* nothing it does not. * nothing it does not.
*/ */
export function AgentPreview({ name, description, icon }: any) { export function AgentPreview({ name, description, icon }: any) {
const { t } = useTranslation();
const Icon = agentIconFor(icon); const Icon = agentIconFor(icon);
return ( return (
<div className="rounded-xl border border-border bg-surface-subtle p-3"> <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"> <div className="mt-2 flex items-start gap-2.5 rounded-lg border border-border bg-surface p-2.5 shadow-xs">
<span <span
aria-hidden="true" aria-hidden="true"

View File

@@ -10,6 +10,7 @@ import {
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces'; import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
import { getSkillsForPage } from '@/lib/skills/registry'; import { getSkillsForPage } from '@/lib/skills/registry';
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary'; import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
import { useTranslation } from 'react-i18next';
import { import {
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip, AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
SettingRow, Workspace, SettingRow, Workspace,
@@ -107,6 +108,7 @@ export function AgentConfigure({
rather than fetched here so this component stays a form over `fields`. */ rather than fetched here so this component stays a form over `fields`. */
toolCatalogue = [], toolCatalogue = [],
}: any) { }: any) {
const { t } = useTranslation();
/* Sections are open by default: this is a document, and one that greets its /* 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 author with four closed headers hides the thing they came to write. Closing
is for focus, not for the initial reading. */ is for focus, not for the initial reading. */
@@ -240,17 +242,17 @@ export function AgentConfigure({
<DocSection <DocSection
id="identity" id="identity"
icon={IdCard} icon={IdCard}
title="Identity" title={t('agentConfigure.identity')}
description="How this agent is presented to people using Owliver." description={t('agentConfigure.howThisAgentIsPresentedTo')}
open={open.identity} open={open.identity}
onToggle={() => toggleSection('identity')} onToggle={() => toggleSection('identity')}
> >
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start"> <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"> <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 <Input
value={fields.name} value={fields.name}
placeholder="e.g. Positions Agent" placeholder={t('agentConfigure.eGPositionsAgent')}
onChange={(e) => set({ name: e.target.value })} onChange={(e) => set({ name: e.target.value })}
className="w-full" className="w-full"
/> />
@@ -258,12 +260,12 @@ export function AgentConfigure({
<DocField <DocField
id="identity-description" id="identity-description"
label="Description" label={t('agentConfigure.description')}
hint="One line, shown under the name when choosing an agent." hint={t('agentConfigure.oneLineShownUnderTheName')}
> >
<Input <Input
value={fields.description} 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 })} onChange={(e) => set({ description: e.target.value })}
className="w-full" className="w-full"
/> />
@@ -271,13 +273,13 @@ export function AgentConfigure({
<DocField <DocField
id="identity-trigger" id="identity-trigger"
label="When to use it" label={t('agentConfigure.whenToUseIt')}
hint="Helps a reader pick the right agent. Never matched against questions." hint={t('agentConfigure.helpsAReaderPickTheRight')}
> >
<Textarea <Textarea
value={fields.trigger} value={fields.trigger}
rows={3} 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 })} onChange={(e) => set({ trigger: e.target.value })}
className="w-full resize-y" 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"> <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 <IconPicker
icons={AGENT_ICONS} icons={AGENT_ICONS}
value={fields.icon} value={fields.icon}
@@ -307,8 +309,8 @@ export function AgentConfigure({
<DocSection <DocSection
id="instructions" id="instructions"
icon={FileText} icon={FileText}
title="Instructions" title={t('agentConfigure.instructions')}
description="How this agent should answer, what it is responsible for, and what it should say when it cannot help." description={t('agentConfigure.howThisAgentShouldAnswerWhat')}
meta={`${instructionWords} word${instructionWords === 1 ? '' : 's'}`} meta={`${instructionWords} word${instructionWords === 1 ? '' : 's'}`}
open={open.instructions} open={open.instructions}
onToggle={() => toggleSection('instructions')} onToggle={() => toggleSection('instructions')}
@@ -333,7 +335,7 @@ export function AgentConfigure({
<span className="text-caption tabular-nums text-ink-4"> <span className="text-caption tabular-nums text-ink-4">
{instructionWords} word{instructionWords === 1 ? '' : 's'} {instructionWords} word{instructionWords === 1 ? '' : 's'}
</span> </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>
</div> </div>
</DocSection> </DocSection>
@@ -342,8 +344,8 @@ export function AgentConfigure({
<DocSection <DocSection
id="capabilities" id="capabilities"
icon={Boxes} icon={Boxes}
title="Capabilities" title={t('agentConfigure.capabilities')}
description="What this agent can reach and use when it answers." description={t('agentConfigure.whatThisAgentCanReachAnd')}
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`} meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
open={open.capabilities} open={open.capabilities}
onToggle={() => toggleSection('capabilities')} onToggle={() => toggleSection('capabilities')}
@@ -354,12 +356,12 @@ export function AgentConfigure({
<GroupHead <GroupHead
id="capabilities-pages" id="capabilities-pages"
icon={LayoutGrid} icon={LayoutGrid}
title="Pages it covers" title={t('agentConfigure.pagesItCovers')}
count={fields.pages.length} count={fields.pages.length}
/> />
<p className="pb-2 text-caption leading-relaxed text-ink-3"> <p className="pb-2 text-caption leading-relaxed text-ink-3">
{coveredLabels.length {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.'} : 'No page selected — this agent has nowhere to answer yet.'}
</p> </p>
{/** {/**
@@ -390,7 +392,7 @@ export function AgentConfigure({
</span> </span>
))} ))}
{!fields.pages.length && ( {!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> </div>
) : ( ) : (
@@ -436,19 +438,18 @@ export function AgentConfigure({
<GroupHead <GroupHead
id="capabilities-skills" id="capabilities-skills"
icon={Boxes} icon={Boxes}
title="Skills" title={t('agentConfigure.skills')}
count={fields.skills.length} count={fields.skills.length}
action={onOpenSkills && ( action={onOpenSkills && (
<Button variant="ghost" size="sm" onClick={onOpenSkills}> <Button variant="ghost" size="sm" onClick={onOpenSkills}>
Browse catalog {t('agentConfigure.browseCatalog')}
</Button> </Button>
)} )}
/> />
{!fields.pages.length ? ( {!fields.pages.length ? (
<p className="mt-2 text-body-sm text-ink-3"> <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 {t('agentConfigure.chooseAPageFirstSkillsBelong')}
this agent has somewhere to answer.
</p> </p>
) : ( ) : (
<div className="mt-2 space-y-3"> <div className="mt-2 space-y-3">
@@ -478,7 +479,7 @@ export function AgentConfigure({
{!fields.skills.length && ( {!fields.skills.length && (
<p className="text-body-sm text-ink-3"> <p className="text-body-sm text-ink-3">
No skills attached. This agent answers from the page&apos;s own reader only. {t('agentConfigure.noSkillsAttachedThisAgentAnswers')}only.
</p> </p>
)} )}
@@ -523,7 +524,7 @@ export function AgentConfigure({
<GroupHead <GroupHead
id="capabilities-tools" id="capabilities-tools"
icon={Wrench} icon={Wrench}
title="Tools" title={t('agentConfigure.tools')}
count={fields.tools.length} count={fields.tools.length}
/> />
@@ -557,8 +558,7 @@ export function AgentConfigure({
{!fields.tools.length && ( {!fields.tools.length && (
<p className="text-body-sm text-ink-3"> <p className="text-body-sm text-ink-3">
No tools attached. This agent can discuss its subject but cannot look {t('agentConfigure.noToolsAttachedThisAgentCan')}
anything up.
</p> </p>
)} )}
@@ -584,9 +584,7 @@ export function AgentConfigure({
</Select> </Select>
<p className="text-caption leading-relaxed text-ink-4"> <p className="text-caption leading-relaxed text-ink-4">
A tool marked <em>writes</em> can propose a change. Nothing is written until {t('agentConfigure.aToolMarked')} <em>writes</em> {t('agentConfigure.canProposeAChangeNothingIs')}
somebody approves it, and the agent can only reach what you could reach
yourself.
</p> </p>
</div> </div>
</div> </div>
@@ -596,7 +594,7 @@ export function AgentConfigure({
<GroupHead <GroupHead
id="capabilities-knowledge" id="capabilities-knowledge"
icon={BookOpen} icon={BookOpen}
title="Knowledge" title={t('agentConfigure.knowledge')}
count={fields.knowledge.length} count={fields.knowledge.length}
action={( action={(
<Button <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> </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" 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]"> <div className="grid gap-2.5 sm:grid-cols-[minmax(0,1fr)_9rem]">
<Field label="Title"> <Field label={t('agentConfigure.title')}>
<Input <Input
value={entry.label} value={entry.label}
placeholder="e.g. Overtime policy" placeholder={t('agentConfigure.eGOvertimePolicy')}
onChange={(e) => { onChange={(e) => {
const next = [...fields.knowledge]; const next = [...fields.knowledge];
next[i] = { ...entry, label: e.target.value }; next[i] = { ...entry, label: e.target.value };
@@ -633,7 +631,7 @@ export function AgentConfigure({
}} }}
/> />
</Field> </Field>
<Field label="Kind"> <Field label={t('agentConfigure.kind')}>
<Select <Select
value={entry.kind} value={entry.kind}
onValueChange={(kind) => { onValueChange={(kind) => {
@@ -655,7 +653,7 @@ export function AgentConfigure({
</div> </div>
{entry.kind === 'link' ? ( {entry.kind === 'link' ? (
<Field label="Link"> <Field label={t('agentConfigure.link')}>
<Input <Input
value={entry.url} value={entry.url}
placeholder="https://…" placeholder="https://…"
@@ -668,8 +666,8 @@ export function AgentConfigure({
</Field> </Field>
) : ( ) : (
<Field <Field
label="What it says" label={t('agentConfigure.whatItSays')}
hint="Quoted back with its source named. Nothing is invented from it." hint={t('agentConfigure.quotedBackWithItsSourceNamed')}
> >
<Textarea <Textarea
value={entry.body} value={entry.body}
@@ -690,14 +688,14 @@ export function AgentConfigure({
knowledge: fields.knowledge.filter((_, x) => x !== i), 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> </Button>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-body-sm text-ink-3"> <p className="text-body-sm text-ink-3">
No knowledge attached. Nothing is invented — this agent answers from skills only. {t('agentConfigure.noKnowledgeAttachedNothingIsInvented')}
</p> </p>
)} )}
</div> </div>
@@ -708,8 +706,8 @@ export function AgentConfigure({
<DocSection <DocSection
id="behavior" id="behavior"
icon={SlidersHorizontal} icon={SlidersHorizontal}
title="Behavior" title={t('agentConfigure.behavior')}
description="How this agent works when it answers." description={t('agentConfigure.howThisAgentWorksWhenIt')}
meta={reasoning?.label} meta={reasoning?.label}
open={open.behavior} open={open.behavior}
onToggle={() => toggleSection('behavior')} onToggle={() => toggleSection('behavior')}
@@ -719,7 +717,7 @@ export function AgentConfigure({
<SettingRow <SettingRow
id="behavior-reasoning" id="behavior-reasoning"
icon={Brain} icon={Brain}
title="Reasoning" title={t('agentConfigure.reasoning')}
description={reasoning?.summary || 'How much work an answer is worth.'} description={reasoning?.summary || 'How much work an answer is worth.'}
control={( control={(
<SegmentedToggle <SegmentedToggle
@@ -735,9 +733,9 @@ export function AgentConfigure({
<SettingRow <SettingRow
id="behavior-starters" id="behavior-starters"
icon={MessageSquare} icon={MessageSquare}
title="Conversation starters" title={t('agentConfigure.conversationStarters')}
value={starterCount ? `${starterCount} configured` : 'None'} value={starterCount ? `${starterCount} configured` : 'None'}
description="Questions offered as chips when this agent opens." description={t('agentConfigure.questionsOfferedAsChipsWhenThis')}
open={setting === 'behavior-starters'} open={setting === 'behavior-starters'}
onToggle={() => toggleSetting('behavior-starters')} onToggle={() => toggleSetting('behavior-starters')}
> >
@@ -746,7 +744,7 @@ export function AgentConfigure({
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<Input <Input
value={starter.label} value={starter.label}
placeholder="e.g. Which roles are at risk?" placeholder={t('agentConfigure.eGWhichRolesAreAt')}
className="max-w-[30rem]" className="max-w-[30rem]"
onChange={(e) => { onChange={(e) => {
const next = [...fields.starters]; const next = [...fields.starters];
@@ -757,7 +755,7 @@ export function AgentConfigure({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label="Remove starter" aria-label={t('agentConfigure.removeStarter')}
onClick={() => set({ starters: fields.starters.filter((_, x) => x !== i) })} onClick={() => set({ starters: fields.starters.filter((_, x) => x !== i) })}
> >
<X className="h-3.5 w-3.5" aria-hidden="true" /> <X className="h-3.5 w-3.5" aria-hidden="true" />
@@ -769,7 +767,7 @@ export function AgentConfigure({
size="sm" size="sm"
onClick={() => set({ starters: [...fields.starters, { label: '', prompt: '' }] })} 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> </Button>
</div> </div>
</SettingRow> </SettingRow>
@@ -777,22 +775,21 @@ export function AgentConfigure({
<div id="behavior-web" className="scroll-mt-28"> <div id="behavior-web" className="scroll-mt-28">
<SettingRow <SettingRow
icon={Globe} icon={Globe}
title="Web search" title={t('agentConfigure.webSearch')}
value={fields.webSearch ? 'On' : 'Off'} value={fields.webSearch ? 'On' : 'Off'}
description="Whether this agent may look outside the workspace." description={t('agentConfigure.whetherThisAgentMayLookOutside')}
control={( control={(
<Switch <Switch
checked={fields.webSearch} checked={fields.webSearch}
onCheckedChange={(webSearch) => set({ webSearch })} onCheckedChange={(webSearch) => set({ webSearch })}
aria-label="Allow web search" aria-label={t('agentConfigure.allowWebSearch')}
/> />
)} )}
/> />
<Collapse open={fields.webSearch}> <Collapse open={fields.webSearch}>
<div className="pb-4"> <div className="pb-4">
<Alert tone="warning" title="No search provider is configured"> <Alert tone="warning" title={t('agentConfigure.noSearchProviderIsConfigured')}>
This deployment has no web search connected, so answers say plainly that {t('agentConfigure.thisDeploymentHasNoWebSearch')}
nothing outside the workspace was consulted.
</Alert> </Alert>
</div> </div>
</Collapse> </Collapse>
@@ -801,9 +798,9 @@ export function AgentConfigure({
<SettingRow <SettingRow
id="behavior-subagents" id="behavior-subagents"
icon={Layers} icon={Layers}
title="Subagents" title={t('agentConfigure.subagents')}
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'} 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'} open={setting === 'behavior-subagents'}
onToggle={() => toggleSetting('behavior-subagents')} onToggle={() => toggleSetting('behavior-subagents')}
last last
@@ -836,7 +833,7 @@ export function AgentConfigure({
&& set({ subagents: [...fields.subagents, id] })} && set({ subagents: [...fields.subagents, id] })}
> >
<SelectTrigger className="w-full sm:w-72"> <SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder="Add a subagent..." /> <SelectValue placeholder={t('agentConfigure.addASubagent')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableSubagents {availableSubagents
@@ -848,9 +845,7 @@ export function AgentConfigure({
</Select> </Select>
<p className="text-caption leading-relaxed text-ink-4"> <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 {t('agentConfigure.aSubagentRunsAsYouWith')}
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.
</p> </p>
</div> </div>
</SettingRow> </SettingRow>

View File

@@ -15,6 +15,7 @@ import { conversationStats, reviewRow } from '@/lib/agents/conversationInsights'
import { allSkills } from '@/lib/skills/registry'; import { allSkills } from '@/lib/skills/registry';
import { usePreferences } from '@/lib/krowHooks'; import { usePreferences } from '@/lib/krowHooks';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** /**
* What this agent has actually been asked. * 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) { export function AgentInsightsPanel({ agentId, agentName }: any) {
const { t } = useTranslation();
const preferences = usePreferences(); const preferences = usePreferences();
const [records] = React.useState(() => readHistory()); 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"> <div className="w-full rounded-2xl border border-border bg-surface p-12 shadow-xs text-center">
<EmptyState <EmptyState
icon={MessageSquare} 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.`} description={`Insights are counted from conversations held on this device. Ask ${agentName || 'this agent'} something in Owliver and the figures appear here.`}
/> />
</div> </div>
@@ -85,25 +87,25 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard <MetricCard
icon={MessageSquare} icon={MessageSquare}
label="Total Conversations" label={t('agentInsightsPanel.totalConversations')}
value={stats.total} value={stats.total}
detail={`Active across ${stats.activeDays} day${stats.activeDays === 1 ? '' : 's'}`} detail={`Active across ${stats.activeDays} day${stats.activeDays === 1 ? '' : 's'}`}
/> />
<MetricCard <MetricCard
icon={HelpCircle} icon={HelpCircle}
label="Questions Asked" label={t('agentInsightsPanel.questionsAsked')}
value={stats.turns} value={stats.turns}
detail={`${stats.averageTurns} per session average`} detail={`${stats.averageTurns} per session average`}
/> />
<MetricCard <MetricCard
icon={Activity} icon={Activity}
label="Surfaces Utilized" label={t('agentInsightsPanel.surfacesUtilized')}
value={stats.pages} value={stats.pages}
detail="Operational pages touched" detail="Operational pages touched"
/> />
<MetricCard <MetricCard
icon={ThumbsUp} icon={ThumbsUp}
label="Feedback Rating" label={t('agentInsightsPanel.feedbackRating')}
value={stats.feedback.score === null ? '—' : `${stats.feedback.score}%`} value={stats.feedback.score === null ? '—' : `${stats.feedback.score}%`}
detail={ detail={
stats.feedback.score === null 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="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-krow-blue" /> <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> </div>
{stats.bySkill.length ? ( {stats.bySkill.length ? (
@@ -138,7 +140,7 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
</div> </div>
) : ( ) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3"> <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>
)} )}
</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="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Wrench className="h-4 w-4 text-krow-blue" /> <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> </div>
{stats.byTool.length ? ( {stats.byTool.length ? (
@@ -166,7 +168,7 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
</div> </div>
) : ( ) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3"> <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>
)} )}
</div> </div>
@@ -178,7 +180,7 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-krow-blue" /> <Clock className="h-4 w-4 text-krow-blue" />
<h4 className="font-heading text-body font-semibold text-ink-1"> <h4 className="font-heading text-body font-semibold text-ink-1">
Recent Conversation Logs {t('agentInsightsPanel.recentConversationLogs')}
</h4> </h4>
</div> </div>
</div> </div>
@@ -194,7 +196,7 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
{row.title || 'General consultation'} {row.title || 'General consultation'}
</p> </p>
<p className="text-caption text-ink-4"> <p className="text-caption text-ink-4">
Surface:{' '} {t('agentInsightsPanel.surface')}{' '}
<span className="font-medium text-ink-3">{row.page || 'Direct'}</span> <span className="font-medium text-ink-3">{row.page || 'Direct'}</span>
</p> </p>
</div> </div>
@@ -215,11 +217,11 @@ export function AgentInsightsPanel({ agentId, agentName }: any) {
> >
{row.feedback.rating === 'up' ? ( {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> </span>

View File

@@ -26,6 +26,7 @@ import { agentCovers, agentScopedDisabled, classifyQuestion } from '@/lib/agents
import { toolsForContext } from '@/lib/skills/tools'; import { toolsForContext } from '@/lib/skills/tools';
import { retrieveKnowledge } from '@/lib/agents/knowledge'; import { retrieveKnowledge } from '@/lib/agents/knowledge';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** /**
* What this agent would do, on a page, before it is published. * 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) { export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }: any) {
const { t } = useTranslation();
const [contextId, setContextId] = React.useState( const [contextId, setContextId] = React.useState(
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId () => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|| CONTEXT_OPTIONS[0].contextId || CONTEXT_OPTIONS[0].contextId
@@ -102,10 +104,10 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
</span> </span>
<div> <div>
<h3 className="font-heading text-body font-semibold text-ink-1"> <h3 className="font-heading text-body font-semibold text-ink-1">
Simulation & Scope Diagnostics {t('agentTestPanel.simulationScopeDiagnostics')}
</h3> </h3>
<p className="text-caption text-ink-4"> <p className="text-caption text-ink-4">
Evaluate real-time intent routing and skill activation without publishing changes. {t('agentTestPanel.evaluateRealTimeIntentRoutingAnd')}
</p> </p>
</div> </div>
</div> </div>
@@ -113,7 +115,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
{dirty && ( {dirty && (
<Badge variant="warning" size="sm" className="gap-1.5"> <Badge variant="warning" size="sm" className="gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-warning animate-pulse" /> <span className="h-1.5 w-1.5 rounded-full bg-warning animate-pulse" />
Testing unsaved draft {t('agentTestPanel.testingUnsavedDraft')}
</Badge> </Badge>
)} )}
</div> </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="grid gap-4 md:grid-cols-12 items-end">
<div className="md:col-span-4 space-y-1.5"> <div className="md:col-span-4 space-y-1.5">
<label className="block text-body-sm font-medium text-ink-2"> <label className="block text-body-sm font-medium text-ink-2">
Contextual Page {t('agentTestPanel.contextualPage')}
</label> </label>
<Select value={contextId} onValueChange={setContextId}> <Select value={contextId} onValueChange={setContextId}>
<SelectTrigger className="w-full bg-surface"> <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"> <div className="md:col-span-8 space-y-1.5">
<label className="block text-body-sm font-medium text-ink-2"> <label className="block text-body-sm font-medium text-ink-2">
Simulated User Query {t('agentTestPanel.simulatedUserQuery')}
</label> </label>
<div className="relative"> <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" /> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-4" />
<Input <Input
value={question} value={question}
onChange={(e) => setQuestion(e.target.value)} 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" className="pl-9 w-full bg-surface"
/> />
</div> </div>
@@ -176,7 +178,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed" hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Play className="h-3 w-3 shrink-0" aria-hidden="true" /> <Play className="h-3 w-3 shrink-0" aria-hidden="true" />
Ask Owliver for real {t('agentTestPanel.askOwliverForReal')}
</button> </button>
)} )}
</div> </div>
@@ -185,7 +187,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
{fields.starters.filter((s) => s.label).length > 0 && ( {fields.starters.filter((s) => s.label).length > 0 && (
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-border/50"> <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"> <span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">
Suggested Starters: {t('agentTestPanel.suggestedStarters')}
</span> </span>
{fields.starters.filter((s) => s.label).map((s, i) => ( {fields.starters.filter((s) => s.label).map((s, i) => (
<button <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="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2 text-ink-1"> <div className="flex items-center gap-2 text-ink-1">
<Cpu className="h-4 w-4 text-krow-blue shrink-0" /> <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> </div>
<p className="text-caption text-ink-3"> <p className="text-caption text-ink-3">
{CLASSIFICATION_COPY[result.classification] || CLASSIFICATION_COPY.structured} {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"> <span className="text-body-sm font-semibold text-krow-blue">
{result.matched.name} {result.matched.name}
</span> </span>
<Badge variant="info" size="sm">Matched</Badge> <Badge variant="info" size="sm">{t('agentTestPanel.matched')}</Badge>
</div> </div>
<p className="text-caption text-ink-3 leading-relaxed"> <p className="text-caption text-ink-3 leading-relaxed">
{result.matched.description} {result.matched.description}
@@ -268,7 +270,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }:
</div> </div>
) : ( ) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3"> <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>
)} )}
</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 justify-between gap-2 text-ink-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-krow-blue shrink-0" /> <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> </div>
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3"> <span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
{result.scoped.length} {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" 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="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> </div>
) : ( ) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3"> <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>
)} )}
</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 justify-between gap-2 text-ink-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Wrench className="h-4 w-4 text-krow-blue shrink-0" /> <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> </div>
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3"> <span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
{result.tools.length} {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> <span className="truncate font-medium text-ink-2">{tool.label}</span>
{tool.requiresApproval ? ( {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> </div>
) : ( ) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3"> <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>
)} )}
</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="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2 text-ink-1"> <div className="flex items-center gap-2 text-ink-1">
<BookOpen className="h-4 w-4 text-krow-blue shrink-0" /> <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> </div>
{result.knowledge.passages.length ? ( {result.knowledge.passages.length ? (

View File

@@ -12,6 +12,7 @@ import { AgentTree } from './AgentTree';
import { BoardSkillList } from './BoardSkillList'; import { BoardSkillList } from './BoardSkillList';
import { SkillCatalog } from './SkillCatalog'; import { SkillCatalog } from './SkillCatalog';
import { SkillDetails } from './SkillDetails'; import { SkillDetails } from './SkillDetails';
import { useTranslation } from 'react-i18next';
/** /**
* Composing an agent: what it is made of, beside what it could be made of. * Composing an agent: what it is made of, beside what it could be made of.
@@ -55,6 +56,7 @@ export function AgentSkillWorkspace({
pendingId = null, pendingId = null,
dirty = false, dirty = false,
}: any) { }: any) {
const { t } = useTranslation();
const [kind, setKind] = React.useState(OWLIVER); const [kind, setKind] = React.useState(OWLIVER);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const [boardQuery, setBoardQuery] = React.useState(''); const [boardQuery, setBoardQuery] = React.useState('');
@@ -295,16 +297,14 @@ export function AgentSkillWorkspace({
return ( return (
<div className="w-full min-w-0 space-y-3"> <div className="w-full min-w-0 space-y-3">
{dirty && ( {dirty && (
<Alert tone="info" title="This agent has unsaved edits"> <Alert tone="info" title={t('agentSkillWorkspace.thisAgentHasUnsavedEdits')}>
Skill changes are being kept in the draft with them. Save the agent to store everything {t('agentSkillWorkspace.skillChangesAreBeingKeptIn')}
together.
</Alert> </Alert>
)} )}
{unknown.length > 0 && ( {unknown.length > 0 && (
<Alert tone="warning" title={`${unknown.length} attached skill${unknown.length === 1 ? '' : 's'} not in the registry`}> <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. {unknown.join(', ')} {t('agentSkillWorkspace.noDefinitionWithThatIdIs')}
Remove it from the tree, or restore the definition.
</Alert> </Alert>
)} )}
@@ -339,7 +339,7 @@ export function AgentSkillWorkspace({
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
<ListTree className="h-3.5 w-3.5" /> <ListTree className="h-3.5 w-3.5" />
Agent tree {t('agentSkillWorkspace.agentTree')}
<span className="tabular-nums text-ink-4"> <span className="tabular-nums text-ink-4">
· {fields.skills.length} skill{fields.skills.length === 1 ? '' : 's'} · {fields.skills.length} skill{fields.skills.length === 1 ? '' : 's'}
</span> </span>
@@ -407,12 +407,12 @@ export function AgentSkillWorkspace({
onOpenChange={setTreeOpen} onOpenChange={setTreeOpen}
side="left" side="left"
size="sm" size="sm"
title="Agent" title={t('agentSkillWorkspace.agent')}
description="What this agent is made of." description={t('agentSkillWorkspace.whatThisAgentIsMadeOf')}
icon={ListTree} icon={ListTree}
footer={( footer={(
<Button variant="outline" onClick={() => setTreeOpen(false)} className="w-full"> <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> </Button>
)} )}
> >

View File

@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
import { agentIconFor } from '@/components/agents/icons'; import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar'; import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { glyphForGroup } from './glyphs'; import { glyphForGroup } from './glyphs';
import { useTranslation } from 'react-i18next';
/** /**
* What this agent is made of. * 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. * skill while trying to look at it.
*/ */
function SkillLeaf({ entry, id, selected, onSelect, onRemove, busy }) { function SkillLeaf({ entry, id, selected, onSelect, onRemove, busy }) {
const { t } = useTranslation();
const Glyph = entry ? glyphForGroup(entry.group) : AlertTriangle; const Glyph = entry ? glyphForGroup(entry.group) : AlertTriangle;
const name = entry?.name || id; 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. */} cannot separate the tint from the surface can read either. */}
<span className="sr-only">{selected ? ' — selected' : ''}</span> <span className="sr-only">{selected ? ' — selected' : ''}</span>
{!entry && ( {!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> </button>
@@ -191,10 +193,11 @@ export function AgentTree({
busy = false, busy = false,
className, className,
}: any) { }: any) {
const { t } = useTranslation();
const Glyph = agentIconFor(agentIcon); const Glyph = agentIconFor(agentIcon);
return ( 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 {/* The root. Not a disclosure: an agent that could be collapsed to
nothing would leave the panel showing a single closed row. */} 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"> <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"> <span className="block truncate font-heading text-body-sm font-semibold text-ink-1">
{agentName || 'New agent'} {agentName || 'New agent'}
</span> </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> </span>
</div> </div>
@@ -220,11 +223,11 @@ export function AgentTree({
<Branch <Branch
id="surfaces" id="surfaces"
icon={LayoutGrid} icon={LayoutGrid}
label="Surfaces" label={t('agentTree.surfaces')}
count={surfaces.length} count={surfaces.length}
open={open.surfaces} open={open.surfaces}
onToggle={() => onToggleBranch('surfaces')} onToggle={() => onToggleBranch('surfaces')}
hint="No page selected — this agent has nowhere to answer yet." hint={t('agentTree.noPageSelectedThisAgentHas')}
> >
{surfaces.map((surface) => ( {surfaces.map((surface) => (
<StaticLeaf key={surface.id} icon={LayoutGrid} label={surface.label} /> <StaticLeaf key={surface.id} icon={LayoutGrid} label={surface.label} />
@@ -235,20 +238,20 @@ export function AgentTree({
<Branch <Branch
id="skills" id="skills"
icon={Zap} icon={Zap}
label="Skills" label={t('agentTree.skills')}
count={skills.length + boardSkills.length} count={skills.length + boardSkills.length}
open={open.skills} open={open.skills}
onToggle={() => onToggleBranch('skills')} onToggle={() => onToggleBranch('skills')}
hint="Nothing yet." hint={t('agentTree.nothingYet')}
> >
<Branch <Branch
id="skills-owliver" id="skills-owliver"
icon={Zap} icon={Zap}
label="Owliver Skills" label={t('agentTree.owliverSkills')}
count={skills.length} count={skills.length}
open={open['skills-owliver']} open={open['skills-owliver']}
onToggle={() => onToggleBranch('skills-owliver')} onToggle={() => onToggleBranch('skills-owliver')}
hint="Nothing attached yet. Add one from the catalog." hint={t('agentTree.nothingAttachedYetAddOneFrom')}
sub sub
> >
{skills.map((id) => ( {skills.map((id) => (
@@ -270,11 +273,11 @@ export function AgentTree({
<Branch <Branch
id="skills-board" id="skills-board"
icon={LayoutTemplate} icon={LayoutTemplate}
label="Board Skills" label={t('agentTree.boardSkills')}
count={boardSkills.length} count={boardSkills.length}
open={open['skills-board']} open={open['skills-board']}
onToggle={() => onToggleBranch('skills-board')} onToggle={() => onToggleBranch('skills-board')}
hint="None drawing on this agent's surfaces." hint={t('agentTree.noneDrawingOnThisAgentsSurfaces')}
sub sub
> >
{boardSkills.map((entry) => ( {boardSkills.map((entry) => (
@@ -291,11 +294,11 @@ export function AgentTree({
<Branch <Branch
id="tools" id="tools"
icon={Wrench} icon={Wrench}
label="Tools" label={t('agentTree.tools')}
count={tools.length} count={tools.length}
open={open.tools} open={open.tools}
onToggle={() => onToggleBranch('tools')} onToggle={() => onToggleBranch('tools')}
hint="Tools arrive with skills. Attach a skill that declares one." hint={t('agentTree.toolsArriveWithSkillsAttachA')}
> >
{tools.map((tool) => ( {tools.map((tool) => (
<StaticLeaf key={tool.name} icon={Wrench} label={tool.label} detail={tool.via} /> <StaticLeaf key={tool.name} icon={Wrench} label={tool.label} detail={tool.via} />
@@ -305,11 +308,11 @@ export function AgentTree({
<Branch <Branch
id="knowledge" id="knowledge"
icon={BookOpen} icon={BookOpen}
label="Knowledge" label={t('agentTree.knowledge')}
count={knowledge.length} count={knowledge.length}
open={open.knowledge} open={open.knowledge}
onToggle={() => onToggleBranch('knowledge')} onToggle={() => onToggleBranch('knowledge')}
hint="Nothing attached. Add knowledge under Configure." hint={t('agentTree.nothingAttachedAddKnowledgeUnderConfigure')}
> >
{knowledge.map((entry, i) => ( {knowledge.map((entry, i) => (
<StaticLeaf <StaticLeaf

View File

@@ -3,6 +3,7 @@ import { LayoutTemplate, Minus, Pencil, Plus, SearchX } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Alert, Button, SearchInput, Switch } from '@/components/ds'; import { Alert, Button, SearchInput, Switch } from '@/components/ds';
import { useTranslation } from 'react-i18next';
/** /**
* Board skills, under the agent that stands beside them. * 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. */ /** One Board skill: what it draws, where, and whether it is switched on. */
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) { function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) {
const { t } = useTranslation();
const switchId = `board-${entry.id}`; const switchId = `board-${entry.id}`;
return ( return (
@@ -66,7 +68,7 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = fals
<LayoutTemplate className="h-3.5 w-3.5" /> <LayoutTemplate className="h-3.5 w-3.5" />
</span> </span>
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3"> <span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
Board Skill {t('boardSkillList.boardSkill')}
</span> </span>
</div> </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`} aria-label={`${attached ? 'Remove' : 'Add'} ${entry.name} ${attached ? 'from' : 'to'} this agent`}
> >
{attached {attached
? <><Minus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove</> ? <><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" />Add to agent</>} : <><Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />{t('boardSkillList.addToAgent')}</>}
</Button> </Button>
</div> </div>
)} )}
@@ -151,7 +153,7 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = fals
to={`/admin/workspace/skills/${entry.id}`} 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" 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> </Link>
</div> </div>
</article> </article>
@@ -163,6 +165,7 @@ export function BoardSkillList({
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total, entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
attachedIds = null, onAttach = null, attachedIds = null, onAttach = null,
}: any) { }: any) {
const { t } = useTranslation();
const searchId = React.useId(); const searchId = React.useId();
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]); const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
@@ -177,22 +180,19 @@ export function BoardSkillList({
}, [entries, onSurfaces]); }, [entries, onSurfaces]);
return ( return (
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4"> <section aria-label={t('boardSkillList.boardSkills')} className="flex min-w-0 flex-col gap-4">
<Alert tone="info" title="Two switches, two different questions"> <Alert tone="info" title={t('boardSkillList.twoSwitchesTwoDifferentQuestions')}>
<strong>Active</strong> is workspace-wide: off means off on every page, for every <strong>{t('boardSkillList.active')}</strong> {t('boardSkillList.isWorkspaceWideOffMeansOff')} <strong>{t('boardSkillList.addToAgent')}</strong> {t('boardSkillList.isOwnershipOnceAnyAgentOwns')}
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.
</Alert> </Alert>
{total > 0 && ( {total > 0 && (
<div> <div>
<label htmlFor={searchId} className="sr-only">Search board skills</label> <label htmlFor={searchId} className="sr-only">{t('boardSkillList.searchBoardSkills')}</label>
<SearchInput <SearchInput
id={searchId} id={searchId}
value={query} value={query}
onChange={onQueryChange} onChange={onQueryChange}
placeholder="Search board skills, sections or pages..." placeholder={t('boardSkillList.searchBoardSkillsSectionsOrPages')}
debounce={0} debounce={0}
/> />
</div> </div>
@@ -201,7 +201,7 @@ export function BoardSkillList({
{total > 0 && ( {total > 0 && (
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 pt-1"> <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"> <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> </p>
<Link <Link
to="/admin/workspace/skills/new" 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 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`} 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> </Link>
</div> </div>
)} )}
@@ -235,20 +235,19 @@ export function BoardSkillList({
{total > 0 ? ( {total > 0 ? (
<> <>
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" /> <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-2.5 text-sm font-semibold text-ink-1">{t('boardSkillList.noBoardSkillsMatchThat')}</p>
<p className="mt-1 text-xs text-ink-3">Try a different word.</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" /> <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"> <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. {t('boardSkillList.aBoardSkillDeclaresASection')}
Add one and the page it names renders it.
</p> </p>
<Button asChild variant="outline" size="sm" className="mt-3.5"> <Button asChild variant="outline" size="sm" className="mt-3.5">
<Link to="/admin/workspace/skills/new"> <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> </Link>
</Button> </Button>
</> </>

View File

@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SearchInput } from '@/components/ds'; import { SearchInput } from '@/components/ds';
import { SkillCard } from './SkillCard'; import { SkillCard } from './SkillCard';
import { useTranslation } from 'react-i18next';
/** /**
* The catalog — every skill this workspace has, browsable. * The catalog — every skill this workspace has, browsable.
@@ -24,10 +25,11 @@ import { SkillCard } from './SkillCard';
*/ */
function CategoryFilter({ groups, value, onChange, total }: any) { function CategoryFilter({ groups, value, onChange, total }: any) {
const { t } = useTranslation();
return ( return (
<div <div
role="group" 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" 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) => { {[{ id: 'all', label: 'All', count: total }, ...groups].map((group) => {
@@ -75,18 +77,19 @@ export function SkillCatalog({
busy = false, busy = false,
pendingId = null, pendingId = null,
}: any) { }: any) {
const { t } = useTranslation();
const searchId = React.useId(); const searchId = React.useId();
return ( 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 className="space-y-3">
<div> <div>
<label htmlFor={searchId} className="sr-only">Search skills</label> <label htmlFor={searchId} className="sr-only">{t('skillCatalog.searchSkills')}</label>
<SearchInput <SearchInput
id={searchId} id={searchId}
value={query} value={query}
onChange={onQueryChange} onChange={onQueryChange}
placeholder="Search skills, capabilities or pages..." placeholder={t('skillCatalog.searchSkillsCapabilitiesOrPages')}
debounce={0} debounce={0}
/> />
</div> </div>
@@ -98,7 +101,7 @@ export function SkillCatalog({
{/* The count is read from the same array the cards come from, so a {/* The count is read from the same array the cards come from, so a
heading cannot claim six above four cards. */} heading cannot claim six above four cards. */}
<p className="text-caption font-medium text-ink-3" aria-live="polite"> <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>} {attachedIds.size > 0 && <span className="font-semibold text-krow-blue"> · {attachedIds.size} enabled</span>}
</p> </p>
@@ -110,7 +113,7 @@ export function SkillCatalog({
hover:border-krow-blue/40 hover:bg-krow-blue-tint focus-visible:outline-none hover:border-krow-blue/40 hover:bg-krow-blue-tint focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-krow-blue/40`} 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> </Link>
</div> </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"> <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" /> <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"> <p className="mt-1 text-caption text-ink-3">
Try a different word, or widen the category filter. {t('skillCatalog.tryADifferentWordOrWiden')}
</p> </p>
</div> </div>
)} )}

View File

@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
import { testTargets } from '@/lib/agents/capabilityTest'; import { testTargets } from '@/lib/agents/capabilityTest';
import { SurfaceSelect, supportedSurfaces } from './SurfaceSelect'; import { SurfaceSelect, supportedSurfaces } from './SurfaceSelect';
import { glyphForGroup } from './glyphs'; import { glyphForGroup } from './glyphs';
import { useTranslation } from 'react-i18next';
/** /**
* One capability, configured — without leaving the workspace. * One capability, configured — without leaving the workspace.
@@ -86,6 +87,7 @@ export function CapabilityBrief({
entry, attached, surface, onSurfaceChange, question, onQuestionChange, entry, attached, surface, onSurfaceChange, question, onQuestionChange,
canTest, target, onTest, test, busy = false, showTest = true, canTest, target, onTest, test, busy = false, showTest = true,
}: any) { }: any) {
const { t } = useTranslation();
const { shapes, described } = entry.capabilities; const { shapes, described } = entry.capabilities;
return ( return (
@@ -93,12 +95,12 @@ export function CapabilityBrief({
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<Badge variant="neutral">{entry.groupLabel}</Badge> <Badge variant="neutral">{entry.groupLabel}</Badge>
{attached {attached
? <Badge variant="success">Enabled for this agent</Badge> ? <Badge variant="success">{t('skillDetails.enabledForThisAgent')}</Badge>
: <Badge variant="neutral">Not enabled</Badge>} : <Badge variant="neutral">{t('skillDetails.notEnabled')}</Badge>}
{entry.custom && <Badge variant="info">Custom</Badge>} {entry.custom && <Badge variant="info">{t('skillDetails.custom')}</Badge>}
</div> </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} /> <SurfaceSelect type={entry.type} value={surface} onChange={onSurfaceChange} name={entry.name} />
</Section> </Section>
@@ -113,15 +115,13 @@ export function CapabilityBrief({
* its reach is the workspace rather than this agent. * its reach is the workspace rather than this agent.
*/} */}
{(surface === 'board' || surface === 'both') && ( {(surface === 'board' || surface === 'both') && (
<Alert tone="warning" title="Board is switched on workspace-wide"> <Alert tone="warning" title={t('skillDetails.boardIsSwitchedOnWorkspaceWide')}>
Enabling Board here uses the real switch every page surface already reads, so the section {t('skillDetails.enablingBoardHereUsesTheReal')}
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> </Alert>
)} )}
{described.length > 0 && ( {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"> <ul className="space-y-1">
{described.map((line) => ( {described.map((line) => (
<li key={line} className="flex gap-2 text-body-sm leading-relaxed text-ink-2"> <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 && ( {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"> <div className="flex flex-wrap gap-1.5">
{shapes.map((shape) => ( {shapes.map((shape) => (
<span <span
@@ -152,7 +152,7 @@ export function CapabilityBrief({
One list, not two: these are the same suggestions the panel offers on One list, not two: these are the same suggestions the panel offers on
the page, so the test asks what the product would ask. */} the page, so the test asks what the product would ask. */}
{entry.suggestions.length > 0 && ( {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"> <ul className="space-y-1">
{entry.suggestions.map((s) => { {entry.suggestions.map((s) => {
const value = s.prompt || s.label; const value = s.prompt || s.label;
@@ -174,7 +174,7 @@ export function CapabilityBrief({
)} )}
> >
&ldquo;{s.label}&rdquo; &ldquo;{s.label}&rdquo;
{picked && <span className="sr-only"> — chosen for the test</span>} {picked && <span className="sr-only"> {t('skillDetails.chosenForTheTest')}</span>}
</button> </button>
</li> </li>
); );
@@ -183,7 +183,7 @@ export function CapabilityBrief({
</Section> </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 ? ( {entry.surfaces.length > 0 ? (
<> <>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -197,17 +197,16 @@ export function CapabilityBrief({
))} ))}
</div> </div>
<p className="text-caption leading-relaxed text-ink-4"> <p className="text-caption leading-relaxed text-ink-4">
A skill still only answers on the pages it names. Enabling it cannot widen that — {t('skillDetails.aSkillStillOnlyAnswersOn')}
the agent&apos;s own surfaces narrow it further.
</p> </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> </Section>
{entry.tools.length > 0 && ( {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"> <ul className="space-y-1.5">
{entry.tools.map((tool) => ( {entry.tools.map((tool) => (
<li key={tool.name} className="rounded-lg border border-border bg-surface px-2.5 py-2"> <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.label}
{!tool.readOnly && ( {!tool.readOnly && (
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-semibold text-warning"> <span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-semibold text-warning">
Writes {t('skillDetails.writes')}
</span> </span>
)} )}
</p> </p>
@@ -231,17 +230,17 @@ export function CapabilityBrief({
{/* Test: the button and the outcome. The question and the answer live in {/* Test: the button and the outcome. The question and the answer live in
the Owliver panel, which is already on this page. */} the Owliver panel, which is already on this page. */}
{showTest && ( {showTest && (
<Section icon={Play} title="Test"> <Section icon={Play} title={t('skillDetails.test')}>
<p className="text-caption leading-relaxed text-ink-3"> <p className="text-caption leading-relaxed text-ink-3">
{canTest {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' : surface === 'board'
? 'A Board section draws on a page rather than answering a question, so there is nothing to ask it here.' ? '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.'} : 'This capability answers on no page this agent covers, so there is nothing to test against.'}
</p> </p>
<Button variant="outline" size="sm" onClick={onTest} disabled={!canTest || busy} className="w-full"> <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> </Button>
<TestState test={test} entryId={entry.id} /> <TestState test={test} entryId={entry.id} />
@@ -255,6 +254,7 @@ export function SkillDetails({
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false, entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
fields = null, onRunTest = null, test = null, fields = null, onRunTest = null, test = null,
}: any) { }: any) {
const { t } = useTranslation();
/** /**
* The surface this would run on. * The surface this would run on.
* *
@@ -308,7 +308,7 @@ export function SkillDetails({
size="default" size="default"
footer={( 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. {/* The one control in this product that changes the agent's skills.
Last, under everything the decision should rest on. */} Last, under everything the decision should rest on. */}
<Button <Button
@@ -317,8 +317,8 @@ export function SkillDetails({
disabled={busy} disabled={busy}
> >
{attached {attached
? <><Minus className="h-3.5 w-3.5" /> Disable for this agent</> ? <><Minus className="h-3.5 w-3.5" /> {t('skillDetails.disableForThisAgent')}</>
: <><Plus className="h-3.5 w-3.5" /> Enable for this agent</>} : <><Plus className="h-3.5 w-3.5" /> {t('skillDetails.enableForThisAgent')}</>}
</Button> </Button>
</> </>
)} )}

View File

@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import { agentIconFor } from '@/components/agents/icons'; import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar'; import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useActiveAgent } from './AgentContext'; import { useActiveAgent } from './AgentContext';
import { useTranslation } from 'react-i18next';
/** /**
* Who is answering, in Owliver's own header. * 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. * constrained reply arrive with no warning.
*/ */
function ConstrainedTag() { function ConstrainedTag() {
const { t } = useTranslation();
return ( 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"> <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> </span>
); );
} }
export function AgentBadge({ page }: any) { export function AgentBadge({ page }: any) {
const { t } = useTranslation();
const { agent, covers } = useActiveAgent(); const { agent, covers } = useActiveAgent();
return ( return (
@@ -68,7 +71,7 @@ export function AgentBadge({ page }: any) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="truncate font-heading text-body-sm font-semibold leading-tight text-ink-1"> <h2 className="truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
Owliver {t('agentBadge.owliver')}
</h2> </h2>
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center gap-1.5">
<p className="truncate text-caption leading-tight text-ink-3">{page}</p> <p className="truncate text-caption leading-tight text-ink-3">{page}</p>

View File

@@ -3,6 +3,7 @@ import { ThumbsDown, ThumbsUp } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks'; import { ResponseDocument } from './ResponseBlocks';
import OwliverAvatar from '@/components/krow/OwliverAvatar'; import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useTranslation } from 'react-i18next';
/** /**
* The thinking state, shown between sending and the first block arriving. * 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. * reader, and the visually-hidden text is what tells one that a reply is coming.
*/ */
export function ThinkingIndicator() { export function ThinkingIndicator() {
const { t } = useTranslation();
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<OwliverAvatar className="h-6 w-6" rounded="rounded-full" /> <OwliverAvatar className="h-6 w-6" rounded="rounded-full" />
@@ -23,7 +25,7 @@ export function ThinkingIndicator() {
/> />
))} ))}
</span> </span>
<span className="sr-only">Owliver is preparing a response</span> <span className="sr-only">{t('assistantMessage.owliverIsPreparingAResponse')}</span>
</div> </div>
); );
} }

View File

@@ -6,6 +6,7 @@ import { useAssistantPanel } from './AssistantPanelContext';
import { ResizeDivider } from './ResizeDivider'; import { ResizeDivider } from './ResizeDivider';
import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport'; import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport';
import OwliverAvatar from '@/components/krow/OwliverAvatar'; import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useTranslation } from 'react-i18next';
const EXPANDED_WIDTH = 620; 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. * in the collapsed state, where there is nowhere else for it to live.
*/ */
function CollapsedTrigger({ page, onRestore }) { function CollapsedTrigger({ page, onRestore }) {
const { t } = useTranslation();
return ( return (
<button <button
type="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" focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
> >
<OwliverAvatar className="h-7 w-7" rounded="rounded-full" /> <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" /> <PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
</button> </button>
); );
@@ -81,6 +83,7 @@ function CollapsedTrigger({ page, onRestore }) {
* instead of pushing the composer off the bottom of it. * instead of pushing the composer off the bottom of it.
*/ */
function MobileWorkspace({ context, onClose }) { function MobileWorkspace({ context, onClose }) {
const { t } = useTranslation();
const viewport = useVisualViewport(); const viewport = useVisualViewport();
/* The page behind an overlay must not scroll: on a touch screen a drag that /* The page behind an overlay must not scroll: on a touch screen a drag that
@@ -105,7 +108,7 @@ function MobileWorkspace({ context, onClose }) {
pointer. */} pointer. */}
<button <button
type="button" type="button"
aria-label="Close the Owliver workspace" aria-label={t('assistant.closeWorkspace')}
onClick={onClose} onClick={onClose}
className="absolute inset-0 bg-ink-1/30 backdrop-blur-[2px] motion-safe:animate-fade-in" 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" pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Owliver workspace" aria-label={t('assistant.workspace')}
> >
<KrowAssistant <KrowAssistant
key={context.id} key={context.id}
@@ -180,6 +183,7 @@ function MobileWorkspace({ context, onClose }) {
* application layout, never a fullscreen takeover. * application layout, never a fullscreen takeover.
*/ */
export function AssistantPanel({ stickyClassName, panelHeightClassName }) { export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
const { t } = useTranslation();
const { const {
context, isOpen, isExpanded, width, minWidth, maxWidth, context, isOpen, isExpanded, width, minWidth, maxWidth,
setWidth, resetWidth, open, close, expand, restore, setWidth, resetWidth, open, close, expand, restore,
@@ -221,7 +225,7 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
return ( return (
<aside <aside
aria-label="Owliver workspace" aria-label={t('assistant.workspace')}
style={{ width: trackWidth }} style={{ width: trackWidth }}
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2). /* No `overflow-hidden` here — see (1). `self-stretch` — see (2).
`min-w-0` — see (3). `min-w-0` — see (3).

View File

@@ -31,7 +31,8 @@ import { buildIntro } from './dynamic';
import { agentScopedDisabled } from '@/lib/agents/runtime'; import { agentScopedDisabled } from '@/lib/agents/runtime';
import { buildOwliverContext } from '@/lib/agents/context'; import { buildOwliverContext } from '@/lib/agents/context';
import { AgentBadge } from './AgentBadge'; import { AgentBadge } from './AgentBadge';
import { useLanguage } from './language'; import { useTranslation } from 'react-i18next';
import { useLanguage } from '@/lib/i18n/language';
import { useActiveAgent } from './AgentContext'; import { useActiveAgent } from './AgentContext';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage'; import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput'; import { PromptInput } from './PromptInput';
@@ -66,12 +67,13 @@ const EMPTY_SUGGESTIONS = [];
* half-remember. * half-remember.
*/ */
function HistoryView({ groups, currentId, onOpen, onForget }) { function HistoryView({ groups, currentId, onOpen, onForget }) {
const { t } = useTranslation();
if (!groups.length) { if (!groups.length) {
return ( return (
<div className="animate-fade-in"> <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"> <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> </p>
</div> </div>
); );
@@ -179,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. * so a narrow panel on a small screen cannot push the composer off the bottom.
*/ */
function SuggestedQuestions({ prompts, open, onSelect }) { function SuggestedQuestions({ prompts, open, onSelect }) {
const { t } = useTranslation();
return ( return (
<div <div
className={cn( className={cn(
@@ -190,7 +193,7 @@ function SuggestedQuestions({ prompts, open, onSelect }) {
aria-hidden={open ? undefined : 'true'} aria-hidden={open ? undefined : 'true'}
> >
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4"> <p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
Suggested questions {t('krowAssistant.suggestedQuestions')}
</p> </p>
{/* The existing chip component, and the existing submit path behind it — {/* The existing chip component, and the existing submit path behind it —
`runPrompt` is the same handler a typed question goes through. */} `runPrompt` is the same handler a typed question goes through. */}
@@ -410,6 +413,7 @@ export default function KrowAssistant({
the conversation. It applies from the next answer onward — answers already the conversation. It applies from the next answer onward — answers already
on screen stay as they were written. */ on screen stay as they were written. */
const language = useLanguage(); const language = useLanguage();
const { t } = useTranslation();
/** /**
* A skill asked for something to happen. The names come from the skill file; * A skill asked for something to happen. The names come from the skill file;
@@ -688,9 +692,12 @@ export default function KrowAssistant({
/* Greeting and suggestions come from live data, so they recompute only when /* Greeting and suggestions come from live data, so they recompute only when
the data or the page actually changes. */ 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( const intro = React.useMemo(
() => buildIntro(context.id, facts, userName), () => buildIntro(context.id, facts, userName, t),
[context.id, facts, userName] [context.id, facts, userName, t]
); );
/** /**
@@ -917,7 +924,7 @@ export default function KrowAssistant({
data-wide={expanded || undefined} data-wide={expanded || undefined}
className={cn('relative flex flex-col overflow-hidden', className)} className={cn('relative flex flex-col overflow-hidden', className)}
role="region" role="region"
aria-label="Owliver assistant" aria-label={t('assistant.assistant')}
> >
{/* Header */} {/* Header */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5"> <div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
@@ -940,7 +947,7 @@ export default function KrowAssistant({
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{/* The way back to a clean panel, positioned in front of History */} {/* The way back to a clean panel, positioned in front of History */}
{canGoHome && ( {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 {/* History lives with the other window controls rather than in the
body, so the layout of the panel is unchanged whether or not body, so the layout of the panel is unchanged whether or not
@@ -956,13 +963,13 @@ export default function KrowAssistant({
/> />
{expanded {expanded
? onRestore && ( ? 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 && ( : 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 && ( {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>
</div> </div>
@@ -998,7 +1005,7 @@ export default function KrowAssistant({
<button <button
type="button" type="button"
onClick={clearTest} 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 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`} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
> >

View File

@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** /**
* PromptChips — suggested prompts. * PromptChips — suggested prompts.
@@ -30,6 +31,7 @@ export function PromptChips({
something invisible is a keyboard user losing their place. */ something invisible is a keyboard user losing their place. */
focusable = true, focusable = true,
}: any) { }: any) {
const { t } = useTranslation();
const chipRefs = React.useRef([]); const chipRefs = React.useRef([]);
const visible = max ? prompts.slice(0, max) : prompts; const visible = max ? prompts.slice(0, max) : prompts;
@@ -47,7 +49,7 @@ export function PromptChips({
return ( return (
<div <div
role="group" role="group"
aria-label="Suggested prompts" aria-label={t('assistant.suggestedPrompts')}
className={cn( className={cn(
'flex flex-wrap gap-1.5', 'flex flex-wrap gap-1.5',
align === 'center' ? 'justify-center' : 'justify-start', align === 'center' ? 'justify-center' : 'justify-start',

View File

@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { ArrowUp, Square } from 'lucide-react'; import { ArrowUp, Square } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** /**
* PromptInput — the assistant's primary action. * PromptInput — the assistant's primary action.
@@ -28,6 +29,7 @@ export function PromptInput({
align = 'left', align = 'left',
className = '', className = '',
}: any) { }: any) {
const { t } = useTranslation();
const textareaRef = React.useRef(null); const textareaRef = React.useRef(null);
const [focused, setFocused] = React.useState(false); const [focused, setFocused] = React.useState(false);
@@ -79,7 +81,7 @@ export function PromptInput({
onFocus={() => setFocused(true)} onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)} onBlur={() => setFocused(false)}
disabled={busy} disabled={busy}
aria-label="Ask Owliver" aria-label={t('assistant.ask')}
aria-busy={busy || undefined} aria-busy={busy || undefined}
className={cn( className={cn(
'block w-full resize-none bg-transparent leading-relaxed text-ink-1', 'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
@@ -101,7 +103,7 @@ export function PromptInput({
isCenter ? 'text-center' : 'text-left' isCenter ? 'text-center' : 'text-left'
)} )}
> >
{busy ? 'Thinking…' : placeholders[0]} {busy ? t('assistant.thinking') : placeholders[0]}
</span> </span>
)} )}
</div> </div>
@@ -110,7 +112,7 @@ export function PromptInput({
<button <button
type="button" type="button"
onClick={onStop} onClick={onStop}
aria-label="Stop generating" aria-label={t('assistant.stopGenerating')}
className={cn( className={cn(
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2', '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', 'transition-colors duration-base hover:bg-surface-sunken focus-ring',
@@ -123,7 +125,7 @@ export function PromptInput({
<button <button
type="submit" type="submit"
disabled={!value.trim()} disabled={!value.trim()}
aria-label="Send message" aria-label={t('assistant.sendMessage')}
className={cn( className={cn(
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white', 'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
'transition-all duration-base ease-out hover:bg-krow-blue-dark', 'transition-all duration-base ease-out hover:bg-krow-blue-dark',

View File

@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { GripVertical } from 'lucide-react'; import { GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** /**
* ResizeDivider — the drag handle between the dashboard and the Owliver panel. * 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. * limits, and the whole thing is exposed as a `separator` with live values.
*/ */
export function ResizeDivider({ width, min, max, onResize, onDoubleClick, className }) { export function ResizeDivider({ width, min, max, onResize, onDoubleClick, className }) {
const { t } = useTranslation();
const [dragging, setDragging] = React.useState(false); const [dragging, setDragging] = React.useState(false);
/* Pointer events on the window rather than the element: once a drag starts the /* 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 <div
role="separator" role="separator"
aria-orientation="vertical" aria-orientation="vertical"
aria-label="Resize Owliver panel" aria-label={t('assistant.resizePanel')}
aria-valuenow={width} aria-valuenow={width}
aria-valuemin={min} aria-valuemin={min}
aria-valuemax={max} aria-valuemax={max}

View File

@@ -814,8 +814,19 @@ const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] ||
* Everything the landing screen needs, computed from current data. * Everything the landing screen needs, computed from current data.
* `userName` personalizes the greeting where a name is known. * `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); 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 { return {
title: TITLES[contextId]?.(facts, name) ?? 'Owliver', title: TITLES[contextId]?.(facts, name) ?? 'Owliver',
greeting: GREETINGS[contextId]?.(facts) ?? '', 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. */ /** Suggested prompts for the current page and data. */
export function buildPrompts(contextId, facts, workforce = null) { export function buildPrompts(contextId, facts, workforce = null) {
return PROMPTS[contextId]?.(facts, workforce) ?? []; return PROMPTS[contextId]?.(facts, workforce) ?? [];

View File

@@ -95,6 +95,14 @@ export function buildFacts({
const transitions = funnel.slice(1).map((stage, i) => ({ const transitions = funnel.slice(1).map((stage, i) => ({
from: funnel[i].label, from: funnel[i].label,
to: stage.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), rate: pct(stage.count, funnel[i].count),
lost: Math.max(0, funnel[i].count - stage.count), lost: Math.max(0, funnel[i].count - stage.count),
})); }));

View File

@@ -25,7 +25,7 @@ import { agentRequest } from '@/lib/agents/runtime';
import { createAssistantProvider } from './provider'; import { createAssistantProvider } from './provider';
import { preferAgent, resolveIntent } from './routing'; import { preferAgent, resolveIntent } from './routing';
import { doc, text as textBlock, toSnapshots } from './blocks'; import { doc, text as textBlock, toSnapshots } from './blocks';
import { DEFAULT_LANGUAGE } from './language'; import { DEFAULT_LANGUAGE } from '@/lib/i18n/language';
/** One provider instance for the app's lifetime. */ /** One provider instance for the app's lifetime. */
const provider = createAssistantProvider(); const provider = createAssistantProvider();

View File

@@ -16,6 +16,7 @@ import {
Building2, Building2,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** /**
* Animated Flowchart & Department Performance Component * Animated Flowchart & Department Performance Component
@@ -106,6 +107,7 @@ function getDeptColor(index, avgScore) {
/** @param {any} props */ /** @param {any} props */
export function DepartmentPerformance({ items = [], className = '' }: DsProps) { export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table' const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
const [activeDept, setActiveDept] = useState(null); const [activeDept, setActiveDept] = useState(null);
@@ -135,10 +137,10 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
</div> </div>
<div> <div>
<h3 className="font-heading text-body font-semibold text-ink-1"> <h3 className="font-heading text-body font-semibold text-ink-1">
Department Performance & Hiring Flow {t('departmentPerformance.departmentPerformanceHiringFlow')}
</h3> </h3>
<p className="text-caption text-ink-3"> <p className="text-caption text-ink-3">
Interactive pipeline mapping AI score quality & hire velocity across departments {t('departmentPerformance.interactivePipelineMappingAiScoreQuality')}
</p> </p>
</div> </div>
</div> </div>
@@ -156,7 +158,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
)} )}
> >
<Workflow className="h-3.5 w-3.5 text-blue-600" /> <Workflow className="h-3.5 w-3.5 text-blue-600" />
<span>Flowchart</span> <span>{t('departmentPerformance.flowchart')}</span>
</button> </button>
<button <button
type="button" type="button"
@@ -169,7 +171,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
)} )}
> >
<TableIcon className="h-3.5 w-3.5 text-ink-3" /> <TableIcon className="h-3.5 w-3.5 text-ink-3" />
<span>Scorecard</span> <span>{t('departmentPerformance.scorecard')}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -181,7 +183,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
<Trophy className="h-4 w-4" /> <Trophy className="h-4 w-4" />
</div> </div>
<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"> <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> {ranked[0]?.name || 'Chef'} <span className="text-blue-600 font-semibold">({ranked[0]?.avgScore || 97})</span>
</p> </p>
@@ -193,9 +195,9 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
<Building2 className="h-4 w-4" /> <Building2 className="h-4 w-4" />
</div> </div>
<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"> <p className="font-heading text-body-sm font-bold text-ink-1">
{ranked.length} Departments {ranked.length} {t('departmentPerformance.departments')}
</p> </p>
</div> </div>
</div> </div>
@@ -205,7 +207,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
</div> </div>
<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"> <p className="font-heading text-body-sm font-bold text-emerald-600 dark:text-emerald-400">
{overallAvgScore} / 100 {overallAvgScore} / 100
</p> </p>
@@ -217,9 +219,9 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
</div> </div>
<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"> <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> </p>
</div> </div>
</div> </div>
@@ -255,29 +257,29 @@ 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-blue-600 dark:text-blue-400"> <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" /> <span className="h-2 w-2 rounded-full bg-blue-600 animate-pulse" />
Pipeline Source {t('departmentPerformance.pipelineSource')}
</span> </span>
<h4 className="font-heading text-body-lg font-bold text-ink-1"> <h4 className="font-heading text-body-lg font-bold text-ink-1">
Talent Engine {t('departmentPerformance.talentEngine')}
</h4> </h4>
</div> </div>
</div> </div>
<div className="text-right"> <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"> <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> </span>
</div> </div>
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-3 border-t border-border/60 pt-3 text-caption"> <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"> <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"> <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> </p>
</div> </div>
<div className="rounded-xl bg-surface-subtle p-2.5"> <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"> <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> {overallAvgScore} <span className="text-caption font-normal text-ink-3">/ 100</span>
</p> </p>
@@ -297,35 +299,35 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
</div> </div>
<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"> <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> </span>
<h4 className="font-heading text-body-lg font-bold text-ink-1"> <h4 className="font-heading text-body-lg font-bold text-ink-1">
Verified Quality Impact {t('departmentPerformance.verifiedQualityImpact')}
</h4> </h4>
</div> </div>
</div> </div>
<div className="text-right"> <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"> <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> </span>
</div> </div>
</div> </div>
<div className="mt-3 grid grid-cols-3 gap-2 border-t border-border/60 pt-3 text-caption"> <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"> <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"> <p className="font-semibold text-emerald-600 dark:text-emerald-400 mt-0.5 text-body-sm">
95+ Top {t('departmentPerformance.95Top')}
</p> </p>
</div> </div>
<div className="rounded-xl bg-surface-subtle p-2 text-center"> <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"> <p className="font-semibold text-ink-1 mt-0.5 text-body-sm">
100% Verified {t('departmentPerformance.100Verified')}
</p> </p>
</div> </div>
<div className="rounded-xl bg-surface-subtle p-2 text-center"> <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"> <p className="font-semibold text-amber-600 dark:text-amber-400 mt-0.5 text-body-sm">
4.9 / 5.0 4.9 / 5.0
</p> </p>
@@ -338,9 +340,9 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
<div> <div>
<div className="flex items-center justify-between mb-3"> <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]"> <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> </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>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <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>•</span>
<span className="flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400 font-medium"> <span className="flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400 font-medium">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{dept.avgTimeToHire}d fill velocity {dept.avgTimeToHire}{t('departmentPerformance.dFillVelocity')}
</span> </span>
</> </>
) : null} ) : null}
@@ -460,11 +462,11 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
<table className="w-full min-w-[40rem] text-left text-body-sm"> <table className="w-full min-w-[40rem] text-left text-body-sm">
<thead> <thead>
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4"> <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">{t('departmentPerformance.rank')}</th>
<th className="px-4 py-3">Department</th> <th className="px-4 py-3">{t('departmentPerformance.department')}</th>
<th className="px-4 py-3">Roles Included</th> <th className="px-4 py-3">{t('departmentPerformance.rolesIncluded')}</th>
<th className="px-4 py-3 text-right">Volume</th> <th className="px-4 py-3 text-right">{t('departmentPerformance.volume')}</th>
<th className="px-4 py-3 text-right">Avg AI Score</th> <th className="px-4 py-3 text-right">{t('departmentPerformance.avgAiScore')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
@@ -535,9 +537,9 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
</div> </div>
<div> <div>
<h4 className="font-heading text-title font-bold text-ink-1"> <h4 className="font-heading text-title font-bold text-ink-1">
{activeDept.name} Department {activeDept.name} {t('departmentPerformance.department')}
</h4> </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>
</div> </div>
<button <button
@@ -552,19 +554,19 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
{/* Quick Key Specs */} {/* 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 className="grid grid-cols-3 gap-3 rounded-xl bg-surface-subtle p-3 text-center border border-border/50">
<div> <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"> <p className="font-heading text-title font-bold text-blue-600 dark:text-blue-400">
{activeDept.avgScore || '—'} {activeDept.avgScore || '—'}
</p> </p>
</div> </div>
<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"> <p className="font-heading text-title font-bold text-ink-1">
{activeDept.count} {activeDept.count}
</p> </p>
</div> </div>
<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"> <p className="font-heading text-title font-bold text-emerald-600 dark:text-emerald-400">
{activeDept.avgTimeToHire ? `${activeDept.avgTimeToHire}d` : '3d'} {activeDept.avgTimeToHire ? `${activeDept.avgTimeToHire}d` : '3d'}
</p> </p>
@@ -575,7 +577,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
{activeDept.hiresList && activeDept.hiresList.length > 0 && ( {activeDept.hiresList && activeDept.hiresList.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<h5 className="text-caption font-semibold uppercase tracking-wider text-ink-4"> <h5 className="text-caption font-semibold uppercase tracking-wider text-ink-4">
Hired Staff Members {t('departmentPerformance.hiredStaffMembers')}
</h5> </h5>
<div className="divide-y divide-border rounded-xl border border-border bg-surface"> <div className="divide-y divide-border rounded-xl border border-border bg-surface">
{activeDept.hiresList.map((h, i) => ( {activeDept.hiresList.map((h, i) => (
@@ -607,7 +609,7 @@ export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
onClick={() => setActiveDept(null)} 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" 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> </button>
</div> </div>
</motion.div> </motion.div>

View File

@@ -3,6 +3,7 @@ import * as React from 'react';
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react'; import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils'; 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', { const alertVariants = cva('flex items-start gap-3 rounded-xl border p-4 animate-slide-down', {
variants: { variants: {
@@ -42,6 +43,7 @@ const ICON_COLORS = {
*/ */
/** @param {any} props */ /** @param {any} props */
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }: DsProps) { export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }: DsProps) {
const { t } = useTranslation();
const Icon = icon || ICONS[tone]; const Icon = icon || ICONS[tone];
return ( return (
@@ -62,7 +64,7 @@ export function Alert({ tone = 'info', title, children, icon, onDismiss, action,
<button <button
type="button" type="button"
onClick={onDismiss} 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" 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" /> <X className="w-3.5 h-3.5" aria-hidden="true" />

View File

@@ -7,6 +7,7 @@ import { SectionHeader } from '@/components/ds/SectionHeader';
import { EmptyState } from '@/components/ds/EmptyState'; import { EmptyState } from '@/components/ds/EmptyState';
import { SkeletonChart } from '@/components/ds/Skeleton'; import { SkeletonChart } from '@/components/ds/Skeleton';
import { BarChart3 } from 'lucide-react'; import { BarChart3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/** /**
* The chart palette. Recharts needs literal colours, not Tailwind classes, so * The chart palette. Recharts needs literal colours, not Tailwind classes, so
@@ -120,6 +121,7 @@ export function ChartContainer({
footer, footer,
className, className,
}: DsProps) { }: DsProps) {
const { t } = useTranslation();
if (loading) return <SkeletonChart className={className} height={height} />; if (loading) return <SkeletonChart className={className} height={height} />;
return ( return (
@@ -159,7 +161,7 @@ export function ChartContainer({
<div className={cn('flex-1 min-w-0', !legend && 'mt-4')} style={{ minHeight: height }}> <div className={cn('flex-1 min-w-0', !legend && 'mt-4')} style={{ minHeight: height }}>
{isEmpty ? ( {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 ? ( ) : raw ? (
children children
) : ( ) : (

View File

@@ -9,6 +9,7 @@ import { LoadingOverlay } from '@/components/ds/LoadingState';
import { Pagination } from '@/components/ds/Pagination'; import { Pagination } from '@/components/ds/Pagination';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { usePreferences } from '@/lib/krowHooks'; import { usePreferences } from '@/lib/krowHooks';
import { useTranslation } from 'react-i18next';
/** /**
* DataTable — the app's one table. * DataTable — the app's one table.
@@ -62,6 +63,7 @@ export function DataTable({
rowClassName, rowClassName,
stickyHeader = false, stickyHeader = false,
}: DsProps) { }: DsProps) {
const { t } = useTranslation();
/* ── Sorting ─────────────────────────────────────────────────────────── */ /* ── Sorting ─────────────────────────────────────────────────────────── */
/* Row height follows the account's density preference. Only the vertical /* Row height follows the account's density preference. Only the vertical
padding changes — same columns, same type, same behaviour. */ padding changes — same columns, same type, same behaviour. */
@@ -205,7 +207,7 @@ export function DataTable({
<Checkbox <Checkbox
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false} checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
onCheckedChange={toggleAll} onCheckedChange={toggleAll}
aria-label="Select all rows on this page" aria-label={t('dataTable.selectAllRowsOnThisPage')}
/> />
</th> </th>
)} )}

View File

@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
import { SearchInput } from '@/components/ds/SearchInput'; import { SearchInput } from '@/components/ds/SearchInput';
import { MultiSelect } from '@/components/ds/MultiSelect'; import { MultiSelect } from '@/components/ds/MultiSelect';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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. * FilterBar — the search + filters row above a list, table or grid.
@@ -39,6 +40,7 @@ export function FilterBar({
allValue = 'all', allValue = 'all',
className = '', className = '',
}: DsProps) { }: DsProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = React.useState(false); const [expanded, setExpanded] = React.useState(false);
const isActive = (filter) => { const isActive = (filter) => {
@@ -96,7 +98,7 @@ export function FilterBar({
> >
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<SlidersHorizontal aria-hidden="true" /> <SlidersHorizontal aria-hidden="true" />
Filters {t('filterBar.filters')}
</span> </span>
{activeFilters.length > 0 && ( {activeFilters.length > 0 && (
<Badge variant="default" size="sm">{activeFilters.length}</Badge> <Badge variant="default" size="sm">{activeFilters.length}</Badge>
@@ -181,7 +183,7 @@ export function FilterBar({
onClick={clearAll} onClick={clearAll}
className="text-caption font-medium text-ink-3 hover:text-ink-1 hover:underline transition-colors focus-ring rounded-sm" 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> </button>
)} )}
</div> </div>

View File

@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { SearchInput } from '@/components/ds/SearchInput'; import { SearchInput } from '@/components/ds/SearchInput';
import { useTranslation } from 'react-i18next';
/** /**
* MultiSelect — pick several values from a list. * MultiSelect — pick several values from a list.
@@ -27,6 +28,7 @@ export function MultiSelect({
align = 'start', align = 'start',
emptyMessage = 'No options', emptyMessage = 'No options',
}: DsProps) { }: DsProps) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
@@ -112,7 +114,7 @@ export function MultiSelect({
<SearchInput <SearchInput
value={query} value={query}
onChange={setQuery} onChange={setQuery}
placeholder="Filter options" placeholder={t('multiSelect.filterOptions')}
size="sm" size="sm"
debounce={0} debounce={0}
autoFocus autoFocus
@@ -163,7 +165,7 @@ export function MultiSelect({
onClick={() => onChange?.([])} 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" 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> </button>
</div> </div>
)} )}

View File

@@ -3,6 +3,7 @@ import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
/** /**
* Builds a compact page list with ellipses: 1 … 4 5 6 … 20. * Builds a compact page list with ellipses: 1 … 4 5 6 … 20.
@@ -52,6 +53,7 @@ export function Pagination({
pageSizeOptions = [10, 25, 50, 100], pageSizeOptions = [10, 25, 50, 100],
className, className,
}: DsProps) { }: DsProps) {
const { t } = useTranslation();
if (pageCount <= 1 && !onPageSizeChange) return null; if (pageCount <= 1 && !onPageSizeChange) return null;
const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null; const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null;
@@ -60,20 +62,20 @@ export function Pagination({
return ( return (
<nav <nav
aria-label="Pagination" aria-label={t('pagination.pagination')}
className={cn('flex flex-wrap items-center justify-between gap-3', className)} className={cn('flex flex-wrap items-center justify-between gap-3', className)}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{from != null && ( {from != null && (
<p className="text-caption text-ink-3 tabular-nums"> <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> <span className="font-medium text-ink-2">{totalItems}</span>
</p> </p>
)} )}
{onPageSizeChange && ( {onPageSizeChange && (
<label className="flex items-center gap-1.5 text-caption text-ink-3"> <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 <select
value={pageSize} value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))} onChange={(e) => onPageSizeChange(Number(e.target.value))}
@@ -93,7 +95,7 @@ export function Pagination({
variant="outline" variant="outline"
size="icon-sm" size="icon-sm"
shape="rounded" shape="rounded"
aria-label="Previous page" aria-label={t('pagination.previousPage')}
disabled={page <= 1} disabled={page <= 1}
onClick={() => onPageChange?.(page - 1)} onClick={() => onPageChange?.(page - 1)}
> >
@@ -125,7 +127,7 @@ export function Pagination({
variant="outline" variant="outline"
size="icon-sm" size="icon-sm"
shape="rounded" shape="rounded"
aria-label="Next page" aria-label={t('pagination.nextPage')}
disabled={page >= pageCount} disabled={page >= pageCount}
onClick={() => onPageChange?.(page + 1)} onClick={() => onPageChange?.(page + 1)}
> >

View File

@@ -2,6 +2,7 @@ import type { DsProps } from './props';
import * as React from 'react'; import * as React from 'react';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
const clamp = (n) => Math.max(0, Math.min(100, Number(n) || 0)); 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 */ /** @param {any} props */
export function ScoreMeter({ label, value, weight, tone = 'score', className }: DsProps) { export function ScoreMeter({ label, value, weight, tone = 'score', className }: DsProps) {
const { t } = useTranslation();
return ( return (
<div className={cn('space-y-1.5', className)}> <div className={cn('space-y-1.5', className)}>
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2 truncate">{label}</span> <span className="text-body-sm text-ink-2 truncate">{label}</span>
<span className="flex items-baseline gap-1.5 shrink-0"> <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 className="text-body-sm font-semibold text-ink-1 tabular-nums">{value}</span>
</span> </span>
</div> </div>

View File

@@ -1,6 +1,7 @@
import type { DsProps } from './props'; import type { DsProps } from './props';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** /**
* Skeleton — a loading placeholder. * 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. */ /** Paragraph placeholder. The last line is short, as real text is. */
/** @param {any} props */ /** @param {any} props */
export function SkeletonText({ lines = 3, className = '' }: DsProps) { export function SkeletonText({ lines = 3, className = '' }: DsProps) {
const { t } = useTranslation();
return ( 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) => ( {Array.from({ length: lines }).map((_, i) => (
<Skeleton <Skeleton
key={i} key={i}
@@ -102,8 +104,9 @@ export function SkeletonCard({ className = '' }: DsProps) {
/** Matches DataTable: header row plus n body rows. */ /** Matches DataTable: header row plus n body rows. */
/** @param {any} props */ /** @param {any} props */
export function SkeletonTable({ rows = 5, columns = 4, className = '' }: DsProps) { export function SkeletonTable({ rows = 5, columns = 4, className = '' }: DsProps) {
const { t } = useTranslation();
return ( 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"> <div className="flex items-center gap-4 px-4 py-3 border-b border-border bg-surface-subtle">
{Array.from({ length: columns }).map((_, i) => ( {Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} className={cn('h-2.5', i === 0 ? 'w-32' : 'flex-1 max-w-24')} /> <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. */ /** n SkeletonRows inside a surface — the common "loading a list" case. */
/** @param {any} props */ /** @param {any} props */
export function SkeletonList({ rows = 5, className = '' }: DsProps) { export function SkeletonList({ rows = 5, className = '' }: DsProps) {
const { t } = useTranslation();
return ( 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) => ( {Array.from({ length: rows }).map((_, i) => (
<SkeletonRow key={i} /> <SkeletonRow key={i} />
))} ))}

View File

@@ -2,6 +2,7 @@ import type { DsProps } from './props';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge'; 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. * 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 */ /** @param {any} props */
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }: DsProps) { export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }: DsProps) {
const { t, i18n } = useTranslation();
const entry = STATUS_MAP[status] || {}; const entry = STATUS_MAP[status] || {};
const resolvedTone = tone || entry.tone || 'neutral'; 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 ( return (
<Badge variant={resolvedTone} size={size} className={cn('gap-1.5', className)} {...props}> <Badge variant={resolvedTone} size={size} className={cn('gap-1.5', className)} {...props}>

View File

@@ -3,6 +3,7 @@ import { Check, Plus, Sparkles, X } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button, Field, Input, Modal, Textarea, toast } from '@/components/ds'; import { Button, Field, Input, Modal, Textarea, toast } from '@/components/ds';
import { TYPE_HINT, TYPE_LABEL } from './challengeMeta'; import { TYPE_HINT, TYPE_LABEL } from './challengeMeta';
import { useTranslation } from 'react-i18next';
/** /**
* Authoring a workforce skill, in the order the concept runs: * Authoring a workforce skill, in the order the concept runs:
@@ -64,6 +65,7 @@ interface AddSkillTrainingProps {
export function AddSkillTraining({ export function AddSkillTraining({
open, onOpenChange, categories = [], onCreate, onUpdate, busy, prefill, startStep, courseId, open, onOpenChange, categories = [], onCreate, onUpdate, busy, prefill, startStep, courseId,
}: AddSkillTrainingProps) { }: AddSkillTrainingProps) {
const { t } = useTranslation();
const [step, setStep] = React.useState(0); const [step, setStep] = React.useState(0);
const [form, setForm] = React.useState(EMPTY); const [form, setForm] = React.useState(EMPTY);
const [confirming, setConfirming] = React.useState(false); const [confirming, setConfirming] = React.useState(false);
@@ -184,7 +186,7 @@ export function AddSkillTraining({
{isLast ? ( {isLast ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => submit('draft')} disabled={busy}> <Button variant="outline" size="sm" onClick={() => submit('draft')} disabled={busy}>
Save draft {t('addSkillTraining.saveDraft')}
</Button> </Button>
{/* Publishing puts a skill in front of the workforce, so it asks {/* Publishing puts a skill in front of the workforce, so it asks
first rather than firing on the same click that reveals it. */} first rather than firing on the same click that reveals it. */}
@@ -201,7 +203,7 @@ export function AddSkillTraining({
</div> </div>
) : ( ) : (
<Button size="sm" onClick={() => setStep(step + 1)} disabled={!canAdvance}> <Button size="sm" onClick={() => setStep(step + 1)} disabled={!canAdvance}>
Next {t('addSkillTraining.next')}
</Button> </Button>
)} )}
</div> </div>
@@ -211,20 +213,20 @@ export function AddSkillTraining({
{step === 0 && ( {step === 0 && (
<div className="space-y-4"> <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 <Input
value={form.title} value={form.title}
onChange={(e) => set({ title: e.target.value })} onChange={(e) => set({ title: e.target.value })}
placeholder="Knife Skills — Basic Cuts" placeholder={t('addSkillTraining.knifeSkillsBasicCuts')}
/> />
</Field> </Field>
<div className="grid gap-4 sm:grid-cols-2"> <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 <Input
value={form.category} value={form.category}
onChange={(e) => set({ category: e.target.value })} onChange={(e) => set({ category: e.target.value })}
placeholder="Kitchen" placeholder={t('addSkillTraining.kitchen')}
list="forge-categories" list="forge-categories"
/> />
</Field> </Field>
@@ -232,8 +234,8 @@ export function AddSkillTraining({
{categories.map((c) => <option key={c} value={c} />)} {categories.map((c) => <option key={c} value={c} />)}
</datalist> </datalist>
<Field label="Difficulty"> <Field label={t('addSkillTraining.difficulty')}>
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Difficulty"> <div className="flex flex-wrap gap-2" role="radiogroup" aria-label={t('addSkillTraining.difficulty')}>
{DIFFICULTIES.map((d) => ( {DIFFICULTIES.map((d) => (
<Choice <Choice
key={d} key={d}
@@ -248,26 +250,26 @@ export function AddSkillTraining({
</Field> </Field>
</div> </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 <Textarea
rows={3} rows={3}
value={form.description} value={form.description}
onChange={(e) => set({ description: e.target.value })} 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>
<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 <Input
value={form.proof_skill} value={form.proof_skill}
onChange={(e) => set({ proof_skill: e.target.value })} onChange={(e) => set({ proof_skill: e.target.value })}
placeholder="Basic Knife Cuts" placeholder={t('addSkillTraining.basicKnifeCuts')}
/> />
</Field> </Field>
{missing.length > 0 && ( {missing.length > 0 && (
<p className="text-caption text-ink-4"> <p className="text-caption text-ink-4">
Still needs {missing.join(', ')}. {t('addSkillTraining.stillNeeds')} {missing.join(', ')}.
</p> </p>
)} )}
</div> </div>
@@ -276,15 +278,15 @@ export function AddSkillTraining({
{step === 1 && ( {step === 1 && (
<div className="space-y-4"> <div className="space-y-4">
<ListBuilder <ListBuilder
label="Training steps" label={t('addSkillTraining.trainingSteps')}
hint="What the workforce is taught, in order." hint={t('addSkillTraining.whatTheWorkforceIsTaughtIn')}
placeholder="Knife grip" placeholder={t('addSkillTraining.knifeGrip')}
values={form.outline} values={form.outline}
onChange={(outline) => set({ outline })} onChange={(outline) => set({ outline })}
addLabel="Add step" 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 <Input
type="number" type="number"
min="1" min="1"
@@ -295,16 +297,14 @@ export function AddSkillTraining({
</Field> </Field>
<p className="rounded-lg border border-border bg-surface-subtle p-3 text-caption leading-relaxed text-ink-3"> <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 {t('addSkillTraining.trainingInForgeIsWrittenInstruction')}
step. Video and attachments are not part of this deployment, so they are not
offered here.
</p> </p>
</div> </div>
)} )}
{step === 2 && ( {step === 2 && (
<div className="space-y-4"> <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"> <div className="grid gap-2 sm:grid-cols-2">
{PROOF_TYPES.map((t) => ( {PROOF_TYPES.map((t) => (
<button <button
@@ -326,14 +326,14 @@ export function AddSkillTraining({
</Field> </Field>
<Field <Field
label="The task" label={t('addSkillTraining.theTask')}
hint="What you are asking the worker to do. This is what they read when the challenge opens." hint={t('addSkillTraining.whatYouAreAskingTheWorker')}
> >
<Textarea <Textarea
rows={3} rows={3}
value={form.prompt} value={form.prompt}
onChange={(e) => set({ prompt: e.target.value })} 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> </Field>
</div> </div>
@@ -344,24 +344,23 @@ export function AddSkillTraining({
<div className="rounded-lg border border-krow-blue/30 bg-krow-blue-tint/40 p-3"> <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"> <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" /> <Sparkles className="h-3 w-3" aria-hidden="true" />
Owliver evaluates {t('addSkillTraining.owliverEvaluates')}
</p> </p>
<p className="mt-1 text-caption leading-relaxed text-ink-3"> <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 {t('addSkillTraining.eachLineIsScoredAgainstThe')}
in a video — “safe hand position”, not “does it well”.
</p> </p>
</div> </div>
<ListBuilder <ListBuilder
label="Evaluation criteria" label={t('addSkillTraining.evaluationCriteria')}
placeholder="Safe hand position" placeholder={t('addSkillTraining.safeHandPosition')}
values={form.criteria} values={form.criteria}
onChange={(c) => set({ criteria: c })} onChange={(c) => set({ criteria: c })}
addLabel="Add criterion" addLabel="Add criterion"
/> />
<div className="grid gap-4 sm:grid-cols-2"> <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 <Input
type="number" type="number"
min="1" min="1"
@@ -370,11 +369,11 @@ export function AddSkillTraining({
onChange={(e) => set({ pass_score: e.target.value })} onChange={(e) => set({ pass_score: e.target.value })}
/> />
</Field> </Field>
<Field label="Badge awarded" hint="Optional. Written onto the profile on a pass."> <Field label={t('addSkillTraining.badgeAwarded')} hint={t('addSkillTraining.optionalWrittenOntoTheProfileOn')}>
<Input <Input
value={form.badge_reward} value={form.badge_reward}
onChange={(e) => set({ badge_reward: e.target.value })} onChange={(e) => set({ badge_reward: e.target.value })}
placeholder="Knife Certified" placeholder={t('addSkillTraining.knifeCertified')}
/> />
</Field> </Field>
</div> </div>
@@ -383,33 +382,32 @@ export function AddSkillTraining({
{step === 4 && ( {step === 4 && (
<div className="space-y-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 <Review
label="Training" label={t('addSkillTraining.training')}
value={outline.length ? `${outline.length} ${outline.length === 1 ? 'step' : 'steps'}` : 'No written outline'} value={outline.length ? `${outline.length} ${outline.length === 1 ? 'step' : 'steps'}` : 'No written outline'}
meta={outline.join(' · ')} 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 <Review
label="Owliver evaluation" label={t('addSkillTraining.owliverEvaluation')}
value={criteria.length ? `${criteria.length} ${criteria.length === 1 ? 'criterion' : 'criteria'}` : 'None set'} value={criteria.length ? `${criteria.length} ${criteria.length === 1 ? 'criterion' : 'criteria'}` : 'None set'}
meta={criteria.join(' · ')} meta={criteria.join(' · ')}
/> />
<Review <Review
label="Verification" label={t('addSkillTraining.verification')}
value={`Passes at ${Number(form.pass_score) || 70}`} value={`Passes at ${Number(form.pass_score) || 70}`}
meta={form.badge_reward.trim() ? `Earns ${form.badge_reward.trim()}` : 'No badge'} meta={form.badge_reward.trim() ? `Earns ${form.badge_reward.trim()}` : 'No badge'}
/> />
{confirming ? ( {confirming ? (
<p className="rounded-lg border border-warning/40 bg-warning-muted/40 p-3 text-body-sm leading-relaxed text-ink-2"> <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> {t('addSkillTraining.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.inFrontOfTheWorkforceStraight')}
</p> </p>
) : ( ) : (
<p className="text-caption leading-relaxed text-ink-4"> <p className="text-caption leading-relaxed text-ink-4">
Nothing is saved until you choose. A draft stays out of the workforce&apos;s view until {t('addSkillTraining.nothingIsSavedUntilYouChoose')}h it.
you publish it.
</p> </p>
)} )}
</div> </div>

View File

@@ -5,6 +5,7 @@ import { ArrowRight, Lock, Search, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { isUnlocked } from '@/lib/provingGround'; import { isUnlocked } from '@/lib/provingGround';
import { DIFF_TONE, TYPE_ICON, TYPE_LABEL, typeOf } from './challengeMeta'; import { DIFF_TONE, TYPE_ICON, TYPE_LABEL, typeOf } from './challengeMeta';
import { useTranslation } from 'react-i18next';
/** /**
* Every other challenge, as a directory. * Every other challenge, as a directory.
@@ -44,15 +45,16 @@ export function ChallengeDirectory({
completedIds, completedIds,
profile, profile,
}: ChallengeDirectoryProps) { }: ChallengeDirectoryProps) {
const { t } = useTranslation();
return ( return (
<section aria-labelledby="forge-directory" className="min-w-0"> <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="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"> <div className="min-w-0">
<h2 id="forge-directory" className="text-overline uppercase text-ink-4"> <h2 id="forge-directory" className="text-overline uppercase text-ink-4">
Other challenges {t('challengeDirectory.otherChallenges')}
</h2> </h2>
<p className="mt-1 text-body-sm text-ink-3"> <p className="mt-1 text-body-sm text-ink-3">
Practical challenges that turn a claim into verified evidence. {t('challengeDirectory.practicalChallengesThatTurnAClaim')}
</p> </p>
</div> </div>
@@ -66,14 +68,14 @@ export function ChallengeDirectory({
type="search" type="search"
value={search} value={search}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
placeholder="Search challenges" placeholder={t('challengeDirectory.searchChallenges')}
aria-label="Search challenges" 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 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" placeholder:text-ink-4 focus:border-krow-blue focus:outline-none"
/> />
</div> </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) => { {difficulties.map((d) => {
const active = d.v === difficulty; const active = d.v === difficulty;
return ( return (
@@ -109,7 +111,7 @@ export function ChallengeDirectory({
className="rounded text-caption font-semibold text-krow-blue hover:underline 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" focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
> >
Clear filters {t('challengeDirectory.clearFilters')}
</button> </button>
)} )}
</div> </div>
@@ -124,14 +126,14 @@ export function ChallengeDirectory({
</div> </div>
) : results.length === 0 ? ( ) : results.length === 0 ? (
<div className="border-t border-border py-10 text-center"> <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 <button
type="button" type="button"
onClick={onClear} onClick={onClear}
className="mt-2 rounded text-body-sm font-semibold text-krow-blue hover:underline 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" focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
> >
Clear filters {t('challengeDirectory.clearFilters')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -184,7 +186,7 @@ export function ChallengeDirectory({
</span> </span>
{locked && lock.reasons?.length > 0 && ( {locked && lock.reasons?.length > 0 && (
<span className="mt-1 block truncate text-caption text-warning"> <span className="mt-1 block truncate text-caption text-warning">
Requires {lock.reasons[0]} {t('challengeDirectory.requires')} {lock.reasons[0]}
</span> </span>
)} )}
</span> </span>

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Plus, Sparkles } from 'lucide-react'; import { Plus, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/** /**
* The Forge masthead, written for the person who builds the library. * 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) { export function ForgeHeader({ onCreate = null, _onBrowse = null }: ForgeHeaderProps) {
const { t } = useTranslation();
return ( return (
<header className="flex flex-col gap-4 border-b border-border/60 pb-6 sm:flex-row sm:items-end sm:justify-between"> <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="min-w-0 space-y-1.5">
<div className="flex items-center gap-2"> <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"> <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" /> <Sparkles className="h-3 w-3" />
Workforce Skill Verification {t('forgeHeader.workforceSkillVerification')}
</span> </span>
</div> </div>
<h1 className="font-heading text-display-lg font-bold leading-none tracking-tight text-ink-1 sm:text-[2.25rem]"> <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> </h1>
<p className="max-w-xl text-body leading-relaxed text-ink-3"> <p className="max-w-xl text-body leading-relaxed text-ink-3">
Create skills your workforce can learn, practice and prove. Build practical training {t('forgeHeader.createSkillsYourWorkforceCanLearn')}
and evidence-based challenges that turn real-world work into verified workforce skills.
</p> </p>
</div> </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" 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" /> <Plus className="h-3.5 w-3.5" aria-hidden="true" />
<span>Add Skill Training</span> <span>{t('forgeHeader.addSkillTraining')}</span>
</button> </button>
</div> </div>
</header> </header>

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { ArrowRight, Sparkles } from 'lucide-react'; import { ArrowRight, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/** /**
* Forge Insight — AI Career Insight Card * Forge Insight — AI Career Insight Card
@@ -13,6 +14,7 @@ interface ForgeInsightProps {
} }
export function ForgeInsight({ headline, detail, highlight, actionLabel, onAction }: ForgeInsightProps) { export function ForgeInsight({ headline, detail, highlight, actionLabel, onAction }: ForgeInsightProps) {
const { t } = useTranslation();
return ( 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"> <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"> <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"> <div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-600 dark:text-blue-400" /> <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"> <span className="text-[11px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">
KROW Forge Insight {t('forgeInsight.krowForgeInsight')}
</span> </span>
</div> </div>
<p className="font-heading text-body-lg font-bold text-ink-1">{headline}</p> <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"> <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> </p>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { BookOpen, Layers, ShieldCheck, Target, TrendingUp } from 'lucide-react'; import { BookOpen, Layers, ShieldCheck, Target, TrendingUp } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** /**
* Skill overview — progression, in counted figures. * Skill overview — progression, in counted figures.
@@ -79,13 +80,14 @@ interface ForgeProgressProps {
} }
export function ForgeProgress({ figures = [], loading = false }: ForgeProgressProps) { export function ForgeProgress({ figures = [], loading = false }: ForgeProgressProps) {
const { t } = useTranslation();
return ( 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"> <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 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="flex h-2 w-2 rounded-full bg-blue-600" /> <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"> <h2 id="forge-progress" className="font-heading text-caption font-bold uppercase tracking-wider text-ink-4">
Skill Overview {t('forgeProgress.skillOverview')}
</h2> </h2>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Plus, CheckCircle2, Lightbulb, History, Layers } from 'lucide-react'; import { Plus, CheckCircle2, Lightbulb, History, Layers } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { statusOf } from './challengeMeta'; import { statusOf } from './challengeMeta';
import { useTranslation } from 'react-i18next';
const CREATE_STEPS = [ const CREATE_STEPS = [
'Define skill', 'Define skill',
@@ -27,14 +28,15 @@ interface ForgeRailProps {
} }
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver: _onAskOwliver }: ForgeRailProps) { export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver: _onAskOwliver }: ForgeRailProps) {
const { t } = useTranslation();
return ( return (
<aside <aside
className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3 items-stretch" 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 */} {/* 1. Create Skill Card */}
<Panel <Panel
title="Create Skill" title={t('forgeRail.createSkill')}
icon={Layers} icon={Layers}
iconColor="text-blue-600" 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]" 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" /> <Plus className="h-4 w-4" />
Start a new skill {t('forgeRail.startANewSkill')}
</button> </button>
</Panel> </Panel>
{/* 3. Quick Tips Card */} {/* 3. Quick Tips Card */}
<Panel <Panel
title="Quick Tips" title={t('forgeRail.quickTips')}
icon={Lightbulb} icon={Lightbulb}
iconColor="text-amber-500" iconColor="text-amber-500"
> >
@@ -78,7 +80,7 @@ export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver: _onAskO
{/* 4. Recent Skills Card */} {/* 4. Recent Skills Card */}
{recent.length > 0 && ( {recent.length > 0 && (
<Panel <Panel
title="Recent Skills" title={t('forgeRail.recentSkills')}
icon={History} icon={History}
iconColor="text-purple-600" iconColor="text-purple-600"
> >

View File

@@ -5,6 +5,7 @@ import { ArrowRight, Check, Lock } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import OwliverAvatar from '@/components/krow/OwliverAvatar'; import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf, proofSteps } from './challengeMeta'; 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. * 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) { export function NextSkill({ course, locked, requirements = [], actionLabel, index = 1 }: NextSkillProps) {
const { t } = useTranslation();
if (!course) return null; if (!course) return null;
const type = typeOf(course); const type = typeOf(course);
@@ -87,7 +89,7 @@ export function NextSkill({ course, locked, requirements = [], actionLabel, inde
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<p className="text-overline uppercase tracking-[0.12em] text-krow-blue"> <p className="text-overline uppercase tracking-[0.12em] text-krow-blue">
Next skill to prove {t('nextSkill.nextSkillToProve')}
</p> </p>
<span <span
className={cn( className={cn(
@@ -121,7 +123,7 @@ export function NextSkill({ course, locked, requirements = [], actionLabel, inde
{/* What the proof is judged on. */} {/* What the proof is judged on. */}
{steps.length > 0 && ( {steps.length > 0 && (
<div className="bg-surface p-5 sm:p-7"> <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"> <ol className="mt-3.5">
{steps.map((step, i) => ( {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"> <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. */} {/* How it is submitted, and who grades it. */}
<div className="bg-surface p-5 sm:p-7"> <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"> <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" /> <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"> <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"> <p className="flex items-center gap-2 text-overline uppercase text-warning">
<Lock className="h-3 w-3" aria-hidden="true" /> <Lock className="h-3 w-3" aria-hidden="true" />
Locked for now {t('nextSkill.lockedForNow')}
</p> </p>
<p className="mt-1.5 text-body-sm text-ink-2"> <p className="mt-1.5 text-body-sm text-ink-2">
{outstanding === 1 {outstanding === 1
@@ -259,7 +261,7 @@ export function NextSkill({ course, locked, requirements = [], actionLabel, inde
{/* Quiet metadata, and the action. */} {/* 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"> <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"> <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}`} {course.badge_reward && ` · Earns ${course.badge_reward}`}
</p> </p>

View File

@@ -4,6 +4,7 @@ import { ArrowRight, Clock, Sparkles, BookOpen, ShieldCheck, Flame, Wine, Users,
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Badge } from '@/components/ds'; import { Badge } from '@/components/ds';
import { unlocksLabel } from '@/lib/skillGraph'; import { unlocksLabel } from '@/lib/skillGraph';
import { useTranslation } from 'react-i18next';
import { import {
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, TYPE_ICON, TYPE_LABEL, trainingType, typeOf, DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, TYPE_ICON, TYPE_LABEL, trainingType, typeOf,
} from './challengeMeta'; } from './challengeMeta';
@@ -60,6 +61,7 @@ interface SkillCardProps {
} }
export function SkillCard({ course, onOpen, moduleState }: SkillCardProps) { export function SkillCard({ course, onOpen, moduleState }: SkillCardProps) {
const { t } = useTranslation();
const status = statusOf(course); const status = statusOf(course);
const type = typeOf(course); const type = typeOf(course);
const ProofIcon = TYPE_ICON[type] || TYPE_ICON.roleplay; 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"> <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" /> <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"> <span className="text-[11px] font-semibold text-blue-900 dark:text-blue-300">
Owliver evaluated {t('skillCard.owliverEvaluated')}
</span> </span>
<span className="text-[11px] font-normal text-blue-700/80 dark:text-blue-400/80"> <span className="text-[11px] font-normal text-blue-700/80 dark:text-blue-400/80">
· {criteria.length} {criteria.length === 1 ? 'criterion' : 'criteria'} · {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" 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" /> <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> </span>
) : course.proof_skill ? ( ) : course.proof_skill ? (
<span <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" 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" /> <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>
) : <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" 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 <ArrowRight
className="h-3 w-3 transition-transform duration-200 group-hover:translate-x-1" className="h-3 w-3 transition-transform duration-200 group-hover:translate-x-1"
aria-hidden="true" aria-hidden="true"

View File

@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import { ArrowRight, Check, Sparkles } from 'lucide-react'; import { ArrowRight, Check, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Badge, Drawer } from '@/components/ds'; import { Badge, Drawer } from '@/components/ds';
import { useTranslation } from 'react-i18next';
import { import {
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, trainingKinds, trainingOutline, DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, trainingKinds, trainingOutline,
TYPE_HINT, TYPE_LABEL, typeOf, TYPE_HINT, TYPE_LABEL, typeOf,
@@ -30,6 +31,7 @@ interface SkillDetailProps {
} }
export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailProps) { export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailProps) {
const { t } = useTranslation();
if (!course) return null; if (!course) return null;
const status = statusOf(course); 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> <span className="text-caption text-ink-4">{course.estimated_minutes} min</span>
)} )}
{course.proof_skill && ( {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> </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> <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 ? ( {outline.length > 0 ? (
<ol className="space-y-2"> <ol className="space-y-2">
{outline.map((step, i) => ( {outline.map((step, i) => (
@@ -81,7 +83,7 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
</ol> </ol>
) : ( ) : (
<p className="text-body-sm leading-relaxed text-ink-3"> <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'}. {kinds.length ? kinds.join(' and ').toLowerCase() : 'the challenge itself'}.
</p> </p>
@@ -89,13 +91,13 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
{course.quiz?.length > 0 && ( {course.quiz?.length > 0 && (
<p className="mt-3 text-caption text-ink-4"> <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> </p>
)} )}
</Block> </Block>
{course.challenge && ( {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="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> <p className="mt-1 text-body-sm leading-relaxed text-ink-3">{TYPE_HINT[type]}</p>
{course.challenge.prompt && ( {course.challenge.prompt && (
@@ -108,8 +110,8 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
{criteria.length > 0 && ( {criteria.length > 0 && (
<Block <Block
title="Owliver evaluation" title={t('skillDetail.owliverEvaluation')}
meta="What Owliver checks" meta={t('skillDetail.whatOwliverChecks')}
icon={Sparkles} icon={Sparkles}
> >
<ul className="space-y-2"> <ul className="space-y-2">
@@ -129,12 +131,12 @@ export function SkillDetail({ course, open, onOpenChange, usage }: SkillDetailPr
)} )}
{course.pass_score > 0 && ( {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"> <p className="text-body-sm leading-relaxed text-ink-2">
A submission verifies the skill at{' '} {t('skillDetail.aSubmissionVerifiesTheSkillAt')}{' '}
<span className="font-semibold text-ink-1">{course.pass_score} or above</span> <span className="font-semibold text-ink-1">{course.pass_score} {t('skillDetail.orAbove')}</span>
{course.badge_reward {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> </p>
</Block> </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 have no field behind them, so the two states the records do carry are
the two states shown. */} the two states shown. */}
{usage && (usage.completed > 0 || usage.verified > 0) && ( {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"> <dl className="grid grid-cols-2 gap-3">
{[ {[
{ label: 'Completed', value: usage.completed }, { 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 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" 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 <ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5" className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true" aria-hidden="true"

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Plus, Search } from 'lucide-react'; import { Plus, Search } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SkillCard } from './SkillCard'; import { SkillCard } from './SkillCard';
import { useTranslation } from 'react-i18next';
/** /**
* The Skill Training Library — the working surface of admin Forge. * The Skill Training Library — the working surface of admin Forge.
@@ -60,15 +61,16 @@ export function SkillLibrary({
sectionRef, sectionRef,
moduleStateFor, moduleStateFor,
}: SkillLibraryProps) { }: SkillLibraryProps) {
const { t } = useTranslation();
return ( return (
<section aria-labelledby="skill-library" className="min-w-0" ref={sectionRef}> <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="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"> <div className="min-w-0">
<h2 id="skill-library" className="font-heading text-title font-bold text-ink-1"> <h2 id="skill-library" className="font-heading text-title font-bold text-ink-1">
Skill Training Library {t('skillLibrary.skillTrainingLibrary')}
</h2> </h2>
<p className="mt-1 text-body-sm text-ink-3"> <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> </p>
</div> </div>
@@ -82,21 +84,21 @@ export function SkillLibrary({
type="search" type="search"
value={search} value={search}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
placeholder="Search skills" placeholder={t('skillLibrary.searchSkills')}
aria-label="Search skills" 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 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" placeholder:text-ink-4 focus:border-krow-blue focus:outline-none"
/> />
</div> </div>
<FilterGroup <FilterGroup
label="Skill category" label={t('skillLibrary.skillCategory')}
options={categories} options={categories}
value={category} value={category}
onChange={onCategory} onChange={onCategory}
/> />
<FilterGroup <FilterGroup
label="Publication status" label={t('skillLibrary.publicationStatus')}
options={statuses} options={statuses}
value={status} value={status}
onChange={onStatus} onChange={onStatus}
@@ -115,7 +117,7 @@ export function SkillLibrary({
className="rounded text-caption font-semibold text-krow-blue hover:underline 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" focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
> >
Clear filters {t('skillLibrary.clearFilters')}
</button> </button>
)} )}
</div> </div>
@@ -145,7 +147,7 @@ export function SkillLibrary({
{filtered ? 'Clear filters' : ( {filtered ? 'Clear filters' : (
<> <>
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> <Plus className="h-3.5 w-3.5" aria-hidden="true" />
Add Skill Training {t('skillLibrary.addSkillTraining')}
</> </>
)} )}
</button> </button>

View File

@@ -3,6 +3,7 @@ import { ArrowRight, Check, Circle, Lock, ShieldCheck, Sparkles } from 'lucide-r
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Badge, Button, Drawer, ProgressBar } from '@/components/ds'; import { Badge, Button, Drawer, ProgressBar } from '@/components/ds';
import { levelLabel } from '@/lib/skillGraph'; import { levelLabel } from '@/lib/skillGraph';
import { useTranslation } from 'react-i18next';
/** /**
* Skill progression — a skill as a ladder rather than a pile of courses. * 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) { export function SkillProgressCard({ state, onOpen }: SkillProgressCardProps) {
const { t } = useTranslation();
const status = STATUS_META[state.status] || STATUS_META.not_started; const status = STATUS_META[state.status] || STATUS_META.not_started;
const atTop = !state.nextLevel; 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"> <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.verifiedLevel && <ShieldCheck className="h-3 w-3 text-success" aria-hidden="true" />}
{state.verifiedLabel} {state.verifiedLabel}
{state.verifiedByOverride && <span className="text-ink-4">· admin verified</span>} {state.verifiedByOverride && <span className="text-ink-4">{t('skillProgression.adminVerified')}</span>}
</p> </p>
</div> </div>
<Badge variant={status.variant} size="sm">{status.label}</Badge> <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"> <div className="mb-4 mt-4">
{atTop ? ( {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"> <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"> <span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
{state.completedInNext}/{state.requiredForNext} {state.completedInNext}/{state.requiredForNext}
</span> </span>
@@ -152,13 +154,13 @@ export function SkillProgressCard({ state, onOpen }: SkillProgressCardProps) {
<p className="min-w-0 text-caption text-ink-3"> <p className="min-w-0 text-caption text-ink-3">
{state.nextRequirement ? ( {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> <span className="font-medium text-ink-2">{state.nextRequirement.title}</span>
</> </>
) : atTop ? 'Nothing outstanding' : 'No training authored for this level yet'} ) : atTop ? 'Nothing outstanding' : 'No training authored for this level yet'}
</p> </p>
<span className="inline-flex shrink-0 items-center gap-1 text-caption font-semibold text-krow-blue"> <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" /> <ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
</span> </span>
</div> </div>
@@ -181,6 +183,7 @@ interface SkillProgressDetailProps {
} }
export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining }: SkillProgressDetailProps) { export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining }: SkillProgressDetailProps) {
const { t } = useTranslation();
if (!state) return null; if (!state) return null;
const done = new Set(state.levels.flatMap((l) => l.completed.map((m) => m.id))); 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={ footer={
next ? ( next ? (
<> <>
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Close</Button> <Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>{t('skillProgression.close')}</Button>
<Button size="sm" onClick={() => onStartTraining(next)}>Start training</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 && ( {state.nextLevel && (
<div className="mt-4"> <div className="mt-4">
<div className="mb-1 flex items-baseline justify-between"> <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> <span className="text-caption font-semibold tabular-nums text-ink-1">{state.progressToNext}%</span>
</div> </div>
<ProgressBar value={state.progressToNext} tone="brand" size="sm" /> <ProgressBar value={state.progressToNext} tone="brand" size="sm" />
@@ -220,7 +223,7 @@ export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining
{state.verifiedByOverride && ( {state.verifiedByOverride && (
<p className="mt-3 flex items-center gap-1.5 text-caption text-ink-3"> <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" /> <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> </p>
)} )}
</div> </div>
@@ -230,7 +233,7 @@ export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining
<section key={rung.level}> <section key={rung.level}>
<div className="mb-2 flex items-baseline justify-between gap-2"> <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"> <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> </h3>
<span className="text-caption text-ink-4 tabular-nums"> <span className="text-caption text-ink-4 tabular-nums">
{rung.completed.length}/{rung.modules.length} {rung.completed.length}/{rung.modules.length}
@@ -263,14 +266,14 @@ export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining
{module.title} {module.title}
</p> </p>
<p className="truncate text-[11px] text-ink-4"> <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> </p>
</div> </div>
{complete ? ( {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 ? ( ) : 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> <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> </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> </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"> <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" /> <Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
<div className="min-w-0"> <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"> <p className="text-caption text-ink-2">
Complete {next.title} to reach {state.name} — {levelLabel(next.target_level)}. {t('skillProgression.complete')} {next.title} {t('skillProgression.toReach')} {state.name} — {levelLabel(next.target_level)}{t('skillProgression.owliverVerifiesTheEvidenceAgainst')} {(next.verification_criteria || []).length} {t('skillProgression.criteria')}
Owliver verifies the evidence against {(next.verification_criteria || []).length} criteria.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { ArrowRight, Award, Check, ChevronRight, Compass, FileText, ShieldCheck, Sparkles } from 'lucide-react'; import { ArrowRight, Award, Check, ChevronRight, Compass, FileText, ShieldCheck, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
/** /**
* Your Forge — Modern Process Workflow Cards & Skill Verification Roster * Your Forge — Modern Process Workflow Cards & Skill Verification Roster
@@ -21,6 +22,7 @@ interface YourForgeProps {
} }
export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate }: YourForgeProps) { export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate }: YourForgeProps) {
const { t } = useTranslation();
const empty = skills.length === 0; const empty = skills.length === 0;
return ( return (
@@ -29,7 +31,7 @@ export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate
<div> <div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<h2 id="your-forge" className="font-heading text-title font-bold text-ink-1"> <h2 id="your-forge" className="font-heading text-title font-bold text-ink-1">
Your Verified Skills {t('yourForge.yourVerifiedSkills')}
</h2> </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"> <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'} {skills.length} {skills.length === 1 ? 'Skill' : 'Skills'}
@@ -47,7 +49,7 @@ export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate
onClick={onBrowse} 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]" 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" /> <ArrowRight className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-1" />
</button> </button>
</div> </div>
@@ -77,7 +79,7 @@ export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate
isFirst ? 'text-blue-600 dark:text-blue-400' : 'text-ink-4' isFirst ? 'text-blue-600 dark:text-blue-400' : 'text-ink-4'
)} )}
> >
Step {item.step} {t('yourForge.step')} {item.step}
</span> </span>
<div <div
className={cn( className={cn(

View File

@@ -7,6 +7,7 @@ import { generateInterviewQuestion, evaluateInterview } from '@/lib/krowAi';
import { useCreateInterview } from '@/lib/krowHooks'; import { useCreateInterview } from '@/lib/krowHooks';
import OwliverAvatar from '@/components/krow/OwliverAvatar'; import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
const TOTAL_SECONDS = 180; const TOTAL_SECONDS = 180;
const QUESTION_COUNT = 5; const QUESTION_COUNT = 5;
@@ -20,6 +21,7 @@ interface AIInterviewModalProps {
} }
export default function AIInterviewModal({ open, onClose, application, job, conductor = 'KROW' }: AIInterviewModalProps) { export default function AIInterviewModal({ open, onClose, application, job, conductor = 'KROW' }: AIInterviewModalProps) {
const { t } = useTranslation();
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [phase, setPhase] = useState('intro'); // intro | active | evaluating | done const [phase, setPhase] = useState('intro'); // intro | active | evaluating | done
const [isListening, setIsListening] = useState(false); 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> <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> </span>
{phase === 'active' && ( {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]')}> <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> <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"> <p className="text-[14px] text-[#6B7280] max-w-md mx-auto mb-6">
{language === 'es' {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.` ? `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.`} : `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> </p>
<div className="flex items-center justify-center gap-3 mb-4 text-[12px] text-[#6B7280]"> <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"> <button onClick={() => setTtsEnabled(!ttsEnabled)} className="text-[#0838E0] underline">
{ttsEnabled ? 'Mute' : 'Unmute'} {ttsEnabled ? 'Mute' : 'Unmute'}
</button> </button>
@@ -295,7 +297,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
{/* Language selector */} {/* Language selector */}
<div className="flex items-center justify-center gap-2 mb-6"> <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"> <div className="inline-flex rounded-lg border border-[#E5E7EB] overflow-hidden">
<button <button
onClick={() => setLanguage('en')} 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]' language === 'en' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
)} )}
> >
English {t('aIInterviewModal.english')}
</button> </button>
<button <button
onClick={() => setLanguage('es')} 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]' language === 'es' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
)} )}
> >
Español {t('aIInterviewModal.espaOl')}
</button> </button>
</div> </div>
</div> </div>
<Button onClick={startInterview} className="bg-[#0838E0] hover:bg-[#062BAF] px-8"> <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> </Button>
</div> </div>
)} )}
@@ -338,7 +340,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
<p>{msg.content}</p> <p>{msg.content}</p>
{msg.response_time_seconds != null && msg.response_time_seconds < 8 && ( {msg.response_time_seconds != null && msg.response_time_seconds < 8 && (
<p className="text-[10px] mt-1 opacity-70 inline-flex items-center gap-1"> <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> </p>
)} )}
</div> </div>
@@ -387,7 +389,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
disabled={!transcript.trim()} disabled={!transcript.trim()}
className="bg-[#0838E0] hover:bg-[#062BAF]" className="bg-[#0838E0] hover:bg-[#062BAF]"
> >
Send Answer {t('aIInterviewModal.sendAnswer')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -397,7 +399,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
{phase === 'evaluating' && ( {phase === 'evaluating' && (
<div className="py-12 text-center"> <div className="py-12 text-center">
<Loader2 className="w-10 h-10 text-[#0838E0] animate-spin mx-auto mb-4" /> <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> </div>
)} )}
@@ -410,7 +412,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
)}> )}>
{evaluation.overall_interview_score} {evaluation.overall_interview_score}
</div> </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> <p className="text-[13px] text-[#6B7280] mt-1">{evaluation.hire_recommendation}</p>
</div> </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"> <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" /> <AlertTriangle className="w-4 h-4 text-[#92400E] mt-0.5 shrink-0" />
<div> <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 && ( {evaluation.ai_flags?.length > 0 && (
<p className="text-[11px] text-[#92400E] mt-1">{evaluation.ai_flags.join(', ')}</p> <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> <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> <p className="text-[13px] text-[#6B7280]">{evaluation.summary}</p>
</div> </div>
{evaluation.strengths?.length > 0 && ( {evaluation.strengths?.length > 0 && (
<div> <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"> <ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
{evaluation.strengths.map((s, i) => <li key={i}>{s}</li>)} {evaluation.strengths.map((s, i) => <li key={i}>{s}</li>)}
</ul> </ul>
@@ -451,14 +453,14 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
{evaluation.concerns?.length > 0 && ( {evaluation.concerns?.length > 0 && (
<div> <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"> <ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
{evaluation.concerns.map((c, i) => <li key={i}>{c}</li>)} {evaluation.concerns.map((c, i) => <li key={i}>{c}</li>)}
</ul> </ul>
</div> </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> </div>
)} )}
</DialogContent> </DialogContent>

View File

@@ -1,5 +1,6 @@
import type { UserActivity } from '@/types/entities'; import type { UserActivity } from '@/types/entities';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
ResponsiveContainer, ResponsiveContainer,
LineChart, LineChart,
@@ -33,6 +34,7 @@ interface ActivityChartsProps {
} }
export default function ActivityCharts({ activities }: ActivityChartsProps) { export default function ActivityCharts({ activities }: ActivityChartsProps) {
const { t } = useTranslation();
const timeSeries = useMemo(() => { const timeSeries = useMemo(() => {
const days = last7Days(); const days = last7Days();
return days.map((day) => { return days.map((day) => {
@@ -65,7 +67,7 @@ export default function ActivityCharts({ activities }: ActivityChartsProps) {
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5"> <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}> <ResponsiveContainer width="100%" height={240}>
<LineChart data={timeSeries}> <LineChart data={timeSeries}>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" /> <CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
@@ -81,7 +83,7 @@ export default function ActivityCharts({ activities }: ActivityChartsProps) {
</div> </div>
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5"> <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}> <ResponsiveContainer width="100%" height={240}>
<BarChart data={byType}> <BarChart data={byType}>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" /> <CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
@@ -94,7 +96,7 @@ export default function ActivityCharts({ activities }: ActivityChartsProps) {
</div> </div>
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5"> <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}> <ResponsiveContainer width="100%" height={240}>
<PieChart> <PieChart>
<Pie data={byRole} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label> <Pie data={byRole} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label>

View File

@@ -1,6 +1,7 @@
import type { UserActivity } from '@/types/entities'; import type { UserActivity } from '@/types/entities';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const EVENT_LABELS = { const EVENT_LABELS = {
signup: 'Sign Up', signup: 'Sign Up',
@@ -42,6 +43,7 @@ interface ActivityLogTableProps {
} }
export default function ActivityLogTable({ activities }: ActivityLogTableProps) { export default function ActivityLogTable({ activities }: ActivityLogTableProps) {
const { t } = useTranslation();
const [filter, setFilter] = useState('all'); const [filter, setFilter] = useState('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -62,14 +64,14 @@ export default function ActivityLogTable({ activities }: ActivityLogTableProps)
return ( return (
<div className="bg-white rounded-xl border border-[#E5E7EB] overflow-hidden"> <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"> <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="flex gap-2">
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#9CA3AF]" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#9CA3AF]" />
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} 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" className="pl-8 pr-3 h-8 text-[12px] rounded-md border border-[#E5E7EB] focus:border-[#2563EB] focus:outline-none w-40"
/> />
</div> </div>
@@ -90,18 +92,18 @@ export default function ActivityLogTable({ activities }: ActivityLogTableProps)
<table className="w-full min-w-[40rem] text-[13px]"> <table className="w-full min-w-[40rem] text-[13px]">
<thead className="bg-[#F9FAFB] text-[#6B7280]"> <thead className="bg-[#F9FAFB] text-[#6B7280]">
<tr> <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">{t('activityLogTable.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">{t('activityLogTable.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">{t('activityLogTable.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">{t('activityLogTable.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.time')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[#F3F4F6]"> <tbody className="divide-y divide-[#F3F4F6]">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="text-center py-8 text-[#9CA3AF]"> <td colSpan={5} className="text-center py-8 text-[#9CA3AF]">
No activity found {t('activityLogTable.noActivityFound')}
</td> </td>
</tr> </tr>
) : ( ) : (

View File

@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { Badge, Button, EmptyState, Modal, StatusBadge, toast } from '@/components/ds'; import { Badge, Button, EmptyState, Modal, StatusBadge, toast } from '@/components/ds';
import { useCreateApplication, useJobPostings } from '@/lib/krowHooks'; import { useCreateApplication, useJobPostings } from '@/lib/krowHooks';
import { remainingFor } from '@/lib/hiringRecords'; import { remainingFor } from '@/lib/hiringRecords';
import { useTranslation } from 'react-i18next';
/** /**
* Put a person from the talent pool in front of a vacancy. * 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) { export default function AddToPositionModal({ open, onOpenChange, profile, existing = [], staff = [] }: AddToPositionModalProps) {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: postings = [], isLoading } = useJobPostings(); const { data: postings = [], isLoading } = useJobPostings();
const createApplication = useCreateApplication(); const createApplication = useCreateApplication();
@@ -97,7 +99,7 @@ export default function AddToPositionModal({ open, onOpenChange, profile, existi
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={profile ? `Consider ${profile.full_name} for a position` : 'Consider for a position'} 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" size="lg"
busy={Boolean(busyId)} busy={Boolean(busyId)}
> >
@@ -105,9 +107,9 @@ export default function AddToPositionModal({ open, onOpenChange, profile, existi
{!isLoading && open_.length === 0 && ( {!isLoading && open_.length === 0 && (
<EmptyState <EmptyState
title="No open positions" title={t('addToPositionModal.noOpenPositions')}
description="A position has to be open before anyone can be considered for it." description={t('addToPositionModal.aPositionHasToBeOpen')}
action={<Button variant="outline" onClick={() => { onOpenChange?.(false); navigate('/admin/positions'); }}>Go to Positions</Button>} 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 ? ( {already ? (
<div className="flex shrink-0 items-center gap-2"> <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" /> <StatusBadge status={already.status} size="sm" />
</div> </div>
) : ( ) : (
@@ -141,7 +143,7 @@ export default function AddToPositionModal({ open, onOpenChange, profile, existi
loading={busyId === p.id} loading={busyId === p.id}
disabled={Boolean(busyId)} disabled={Boolean(busyId)}
> >
Consider {t('addToPositionModal.consider')}
</Button> </Button>
)} )}
</li> </li>

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { Mic, Clock, Gauge, Sparkles, Fingerprint, BadgeCheck, Briefcase, ChevronRight, AlertCircle } from 'lucide-react'; import { Mic, Clock, Gauge, Sparkles, Fingerprint, BadgeCheck, Briefcase, ChevronRight, AlertCircle } from 'lucide-react';
import { Image } from '@/components/ui/image'; import { Image } from '@/components/ui/image';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
function getInitials(name) { function getInitials(name) {
return (name || '').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??'; 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) { export default function ApplyIdentityCard({ profile, job, onStart, submitting, onBuildIdentity }: ApplyIdentityCardProps) {
const { t } = useTranslation();
const ready = profile && profile.full_name; const ready = profile && profile.full_name;
const completeness = Math.min(100, Math.round(profile?.profile_completion || 0)); const completeness = Math.min(100, Math.round(profile?.profile_completion || 0));
const skills = profile?.skills || []; 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="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 className="flex items-center justify-between gap-3">
<div> <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> <p className="text-[12px] text-[#6B7280] mt-0.5">{job.role_category} • {job.location}</p>
</div> </div>
<span className="inline-flex items-center gap-1 text-[10px] font-bold tracking-wide text-[#2563EB] uppercase"> <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> </span>
</div> </div>
</div> </div>
@@ -61,32 +63,32 @@ export default function ApplyIdentityCard({ profile, job, onStart, submitting, o
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <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> <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> </div>
<p className="text-[12px] text-[#6B7280] mt-1"> <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> </p>
</div> </div>
</div> </div>
{/* Quick stats */} {/* Quick stats */}
<div className="grid grid-cols-3 gap-2"> <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={t('applyIdentityCard.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={t('applyIdentityCard.krowScore')} 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.profile')} value={`${completeness}%`} icon={<Sparkles className="w-3.5 h-3.5" />} />
</div> </div>
{/* Skills + certs */} {/* Skills + certs */}
<div className="space-y-3"> <div className="space-y-3">
{skills.length > 0 && ( {skills.length > 0 && (
<Row label="Skills"> <Row label={t('applyIdentityCard.skills')}>
{skills.slice(0, 8).map((s, i) => ( {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> <span key={i} className="px-2.5 py-1 rounded-full bg-[#F0F4F8] text-[#1E56C1] text-[11px] font-medium">{s}</span>
))} ))}
</Row> </Row>
)} )}
{certs.length > 0 && ( {certs.length > 0 && (
<Row label="Certifications"> <Row label={t('applyIdentityCard.certifications')}>
{certs.slice(0, 6).map((c, i) => ( {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"> <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} <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]"> <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" /> <AlertCircle className="w-4 h-4 text-[#B45309] mt-0.5 shrink-0" />
<div> <div>
<p className="text-[12px] font-medium text-[#92400E]">Your KROW Identity is light.</p> <p className="text-[12px] font-medium text-[#92400E]">{t('applyIdentityCard.yourKrowIdentityIsLight')}</p>
<p className="text-[11px] text-[#B45309] mt-0.5">You can still interview now — building your identity improves matches.</p> <p className="text-[11px] text-[#B45309] mt-0.5">{t('applyIdentityCard.youCanStillInterviewNowBuilding')}</p>
</div> </div>
</div> </div>
)} )}
{/* Interview meta */} {/* Interview meta */}
<div className="flex items-center justify-center gap-3 text-[12px] text-[#6B7280]"> <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"><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" /> Voice or text</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" /> Instant score</span> <span className="inline-flex items-center gap-1.5"><Gauge className="w-3.5 h-3.5" /> {t('applyIdentityCard.instantScore')}</span>
</div> </div>
{/* Actions */} {/* Actions */}
@@ -132,7 +134,7 @@ export default function ApplyIdentityCard({ profile, job, onStart, submitting, o
onClick={onBuildIdentity} 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" 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> </button>
)} )}
</div> </div>

View File

@@ -11,6 +11,7 @@ import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal'; import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { base44 } from '@/api/base44Client'; import { base44 } from '@/api/base44Client';
import { useTranslation } from 'react-i18next';
const STATUS_STYLES = { const STATUS_STYLES = {
hired: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Hired', card: 'bg-white border-[#E5E7EB]' }, 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) { export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }: CandidateCardProps) {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const position = jobTitle || application.job_title || ''; const position = jobTitle || application.job_title || '';
const status = application.status || 'applied'; const status = application.status || 'applied';
@@ -218,7 +220,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
<span>{application.phone}</span> <span>{application.phone}</span>
)} )}
{application.years_experience != null && ( {application.years_experience != null && (
<span>{application.years_experience}yr exp</span> <span>{application.years_experience}{t('candidateCard.yrExp')}</span>
)} )}
</div> </div>
{application.ai_match_label && ( {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] 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" 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" /> <ArrowRight className="w-3 h-3" aria-hidden="true" />
</button> </button>
@@ -269,41 +271,41 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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"> <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" /> <ChevronDown className="w-3.5 h-3.5" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl"> <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"> <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]" /> <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>
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg"> <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]" /> <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>
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg"> <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]" /> <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> </DropdownMenuItem>
{onHire && ( {onHire && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg"> <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]" /> <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>
)} )}
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg"> <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]" /> <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>
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg"> <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]" /> <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> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -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 { 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 InterviewResults from '@/components/krow/InterviewResults';
import WorkforceReputation from '@/components/krow/WorkforceReputation'; import WorkforceReputation from '@/components/krow/WorkforceReputation';
import { useTranslation } from 'react-i18next';
const METRIC_CONFIG = [ const METRIC_CONFIG = [
{ key: 'experience', label: 'Experience', Icon: Award }, { key: 'experience', label: 'Experience', Icon: Award },
@@ -37,6 +38,7 @@ interface CandidateExpandedDetailsProps {
} }
export default function CandidateExpandedDetails({ application }: CandidateExpandedDetailsProps) { export default function CandidateExpandedDetails({ application }: CandidateExpandedDetailsProps) {
const { t } = useTranslation();
const breakdown = application.score_breakdown || {}; const breakdown = application.score_breakdown || {};
const strengths = application.ai_strengths || []; const strengths = application.ai_strengths || [];
const gaps = application.ai_gaps || []; const gaps = application.ai_gaps || [];
@@ -47,7 +49,7 @@ export default function CandidateExpandedDetails({ application }: CandidateExpan
if (!hasScoreData && strengths.length === 0 && gaps.length === 0 && !hasInterview) { if (!hasScoreData && strengths.length === 0 && gaps.length === 0 && !hasInterview) {
return ( return (
<div className="mt-3 pt-4 border-t border-[#F3F4F6]"> <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> </div>
); );
} }
@@ -60,7 +62,7 @@ export default function CandidateExpandedDetails({ application }: CandidateExpan
{/* Detailed Match Signals — 15 Score Dimensions */} {/* Detailed Match Signals — 15 Score Dimensions */}
{Object.keys(breakdown).length > 0 && ( {Object.keys(breakdown).length > 0 && (
<div className="space-y-2"> <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"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{METRIC_CONFIG.map(({ key, label, Icon }) => { {METRIC_CONFIG.map(({ key, label, Icon }) => {
const val = breakdown[key] ?? 90; const val = breakdown[key] ?? 90;
@@ -81,7 +83,7 @@ export default function CandidateExpandedDetails({ application }: CandidateExpan
{/* AI Summary */} {/* AI Summary */}
{application.ai_summary && ( {application.ai_summary && (
<div> <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> <p className="text-[13px] text-[#475569] leading-relaxed font-normal">{application.ai_summary}</p>
</div> </div>
)} )}
@@ -138,7 +140,7 @@ export default function CandidateExpandedDetails({ application }: CandidateExpan
{/* AI Recommendation */} {/* AI Recommendation */}
{application.ai_recommendation && ( {application.ai_recommendation && (
<div className="bg-[#F0FDF4] border border-[#BBF7D0] rounded-xl p-3"> <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> </div>
)} )}

View File

@@ -5,8 +5,10 @@ import { Input } from '@/components/ui/input';
import { Plus, Trash2, ShieldCheck, Loader2 } from 'lucide-react'; import { Plus, Trash2, ShieldCheck, Loader2 } from 'lucide-react';
import { useCertifications, useCreateCertification, useDeleteCertification } from '@/lib/krowHooks'; import { useCertifications, useCreateCertification, useDeleteCertification } from '@/lib/krowHooks';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
export default function CertificationManager() { export default function CertificationManager() {
const { t } = useTranslation();
const { data: certifications = [], isLoading } = useCertifications(); const { data: certifications = [], isLoading } = useCertifications();
const createCert = useCreateCertification(); const createCert = useCreateCertification();
const deleteCert = useDeleteCertification(); const deleteCert = useDeleteCertification();
@@ -39,16 +41,16 @@ export default function CertificationManager() {
<DialogTrigger asChild> <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"> <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]" /> <ShieldCheck className="w-4 h-4 text-[#0838E0]" />
<span className="hidden sm:inline">Manage Certifications</span> <span className="hidden sm:inline">{t('certificationManager.manageCertifications')}</span>
<span className="sm:hidden">Certs</span> <span className="sm:hidden">{t('certificationManager.certs')}</span>
</button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Manage Certifications</DialogTitle> <DialogTitle>{t('certificationManager.manageCertifications')}</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-[13px] text-[#6B7280] -mt-1"> <p className="text-[13px] text-[#6B7280] -mt-1">
These are the certification options applicants can select and upload when applying. {t('certificationManager.theseAreTheCertificationOptionsApplicants')}
</p> </p>
{/* Add new */} {/* Add new */}
@@ -56,7 +58,7 @@ export default function CertificationManager() {
<Input <Input
value={newCert} value={newCert}
onChange={(e) => setNewCert(e.target.value)} onChange={(e) => setNewCert(e.target.value)}
placeholder="e.g. OSHA 30" placeholder={t('certificationManager.eGOsha30')}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
className="flex-1" className="flex-1"
/> />
@@ -67,7 +69,7 @@ export default function CertificationManager() {
className="bg-[#0838E0] hover:bg-[#062BAF]" className="bg-[#0838E0] hover:bg-[#062BAF]"
> >
{createCert.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />} {createCert.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Add {t('certificationManager.add')}
</Button> </Button>
</div> </div>
@@ -77,7 +79,7 @@ export default function CertificationManager() {
<div key={i} className="h-10 bg-[#F3F4F6] rounded-lg animate-pulse" /> <div key={i} className="h-10 bg-[#F3F4F6] rounded-lg animate-pulse" />
))} ))}
{!isLoading && certifications.length === 0 && ( {!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) => ( {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]"> <div key={cert.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-[#F9FAFB] border border-[#E5E7EB]">

View File

@@ -1,6 +1,7 @@
import type { JobApplication, Staff } from '@/types/entities'; import type { JobApplication, Staff } from '@/types/entities';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts'; 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' }; const TIER_COLORS = { Skilled: '#0838E0', 'Cross-Trained': '#333F48', Beginner: '#D1E0D7' };
@@ -10,6 +11,7 @@ interface HiringAnalyticsProps {
} }
export default function HiringAnalytics({ applications, staff }: HiringAnalyticsProps) { export default function HiringAnalytics({ applications, staff }: HiringAnalyticsProps) {
const { t } = useTranslation();
const byTier = useMemo(() => { const byTier = useMemo(() => {
const tiers = ['Skilled', 'Cross-Trained', 'Beginner']; const tiers = ['Skilled', 'Cross-Trained', 'Beginner'];
return tiers.map(tier => ({ return tiers.map(tier => ({
@@ -49,27 +51,27 @@ export default function HiringAnalytics({ applications, staff }: HiringAnalytics
return ( return (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm"> <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> <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} total hires · {recentHires} in last 7 days</p> <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="grid grid-cols-3 gap-3 mb-6">
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]"> <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-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>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]"> <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-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>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]"> <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-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> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div> <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}> <ResponsiveContainer width="100%" height={180}>
<BarChart data={byTier}> <BarChart data={byTier}>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" /> <CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
@@ -86,7 +88,7 @@ export default function HiringAnalytics({ applications, staff }: HiringAnalytics
</div> </div>
<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}> <ResponsiveContainer width="100%" height={180}>
<BarChart data={byRole} layout="vertical"> <BarChart data={byRole} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" /> <CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />

View File

@@ -2,6 +2,7 @@ import type { AIInterview, JobApplication, Staff } from '@/types/entities';
import React from 'react'; import React from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react'; import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface ImpactMetricsProps { interface ImpactMetricsProps {
applications: JobApplication[]; applications: JobApplication[];
@@ -10,6 +11,7 @@ interface ImpactMetricsProps {
} }
export default function ImpactMetrics({ applications, interviews, staff: _staff }: ImpactMetricsProps) { export default function ImpactMetrics({ applications, interviews, staff: _staff }: ImpactMetricsProps) {
const { t } = useTranslation();
const hired = applications.filter(a => a.status === 'hired'); const hired = applications.filter(a => a.status === 'hired');
/* Named for what it is: the applications the AI actually scored. Elsewhere /* Named for what it is: the applications the AI actually scored. Elsewhere
in the product "screened" means the stage (status <> 'applied'), which is a 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>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<h2 className="text-[11px] font-bold text-[#9CA3AF] tracking-wider font-heading"> <h2 className="text-[11px] font-bold text-[#9CA3AF] tracking-wider font-heading">
HIRING IMPACT · OUR MISSION {t('impactMetrics.hiringImpactOurMission')}
</h2> </h2>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">

View File

@@ -2,6 +2,7 @@ import type { AIInterview, JobApplication } from '@/types/entities';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'; import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
import { Mic, CheckCircle2, Clock, AlertTriangle } from 'lucide-react'; import { Mic, CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const VERDICT_COLORS = { hire: '#0838E0', maybe: '#F9E547', no: '#333F48' }; const VERDICT_COLORS = { hire: '#0838E0', maybe: '#F9E547', no: '#333F48' };
@@ -11,6 +12,7 @@ interface InterviewAnalyticsProps {
} }
export default function InterviewAnalytics({ applications, interviews }: InterviewAnalyticsProps) { export default function InterviewAnalytics({ applications, interviews }: InterviewAnalyticsProps) {
const { t } = useTranslation();
const stats = useMemo(() => { const stats = useMemo(() => {
const started = applications.filter(a => a.status === 'interview' || a.interview_id).length; const started = applications.filter(a => a.status === 'interview' || a.interview_id).length;
const completed = interviews.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"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{verdictData.length > 0 && ( {verdictData.length > 0 && (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm"> <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}> <ResponsiveContainer width="100%" height={220}>
<PieChart> <PieChart>
<Pie data={verdictData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={75} label> <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 && ( {categoryData.length > 0 && (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm"> <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}> <ResponsiveContainer width="100%" height={220}>
<BarChart data={categoryData} layout="vertical"> <BarChart data={categoryData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" /> <CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />

View File

@@ -2,6 +2,7 @@ import type { JobApplication } from '@/types/entities';
import React from 'react'; import React from 'react';
import { useInterviews } from '@/lib/krowHooks'; import { useInterviews } from '@/lib/krowHooks';
import { MessageSquare, CheckCircle2, AlertTriangle, Star, FileText } from 'lucide-react'; import { MessageSquare, CheckCircle2, AlertTriangle, Star, FileText } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const VERDICT_STYLES = { const VERDICT_STYLES = {
hire: { bg: 'bg-[#F0FDF4]', text: 'text-[#166534]', label: 'Hire' }, hire: { bg: 'bg-[#F0FDF4]', text: 'text-[#166534]', label: 'Hire' },
@@ -38,6 +39,7 @@ interface InterviewResultsProps {
} }
export default function InterviewResults({ application }: InterviewResultsProps) { export default function InterviewResults({ application }: InterviewResultsProps) {
const { t } = useTranslation();
const { data: interviews = [], isLoading } = useInterviews(); const { data: interviews = [], isLoading } = useInterviews();
const interview = interviews const interview = interviews
.filter((i) => i.application_id === application.id) .filter((i) => i.application_id === application.id)
@@ -46,7 +48,7 @@ export default function InterviewResults({ application }: InterviewResultsProps)
if (isLoading) { if (isLoading) {
return ( return (
<div className="mt-3 pt-4 border-t border-[#F3F4F6]"> <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> </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"> <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" /> <MessageSquare className="w-3.5 h-3.5 text-white" />
</div> </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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${verdictStyle.bg} ${verdictStyle.text}`}> <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"> <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" /> <AlertTriangle className="w-4 h-4 text-[#92400E] mt-0.5 shrink-0" />
<div> <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 && ( {interview.ai_flags?.length > 0 && (
<p className="text-[11px] text-[#92400E] mt-1">{interview.ai_flags.join(', ')}</p> <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 */} {/* Best Fit Roles */}
{interview.best_fit_roles?.length > 0 && ( {interview.best_fit_roles?.length > 0 && (
<div> <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"> <div className="flex items-center gap-2 flex-wrap">
{interview.best_fit_roles.map((role, i) => ( {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]"> <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 */} {/* Q&A Transcript */}
{messages.length > 0 && ( {messages.length > 0 && (
<div> <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"> <div className="space-y-2 max-h-[400px] overflow-y-auto pr-1">
{messages.map((msg, i) => ( {messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}> <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> <p className="whitespace-pre-wrap">{msg.content}</p>
{msg.response_time_seconds != null && msg.response_time_seconds < 8 && ( {msg.response_time_seconds != null && msg.response_time_seconds < 8 && (
<p className="text-[10px] mt-1 opacity-70 inline-flex items-center gap-1"> <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> </p>
)} )}
</div> </div>

View File

@@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Copy, Check, Share2, Mail, Link2, Smartphone, Users } from 'lucide-react'; import { Copy, Check, Share2, Mail, Link2, Smartphone, Users } from 'lucide-react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
interface InviteApplicantsModalProps { interface InviteApplicantsModalProps {
open: boolean; open: boolean;
@@ -12,6 +13,7 @@ interface InviteApplicantsModalProps {
} }
export default function InviteApplicantsModal({ open, onClose, job }: InviteApplicantsModalProps) { export default function InviteApplicantsModal({ open, onClose, job }: InviteApplicantsModalProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [emailRecipients, setEmailRecipients] = useState(''); const [emailRecipients, setEmailRecipients] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
@@ -69,7 +71,7 @@ export default function InviteApplicantsModal({ open, onClose, job }: InviteAppl
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5 text-[#0838E0]" /> <Users className="w-5 h-5 text-[#0838E0]" />
Invite Applicants {t('inviteApplicantsModal.inviteApplicants')}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -86,7 +88,7 @@ export default function InviteApplicantsModal({ open, onClose, job }: InviteAppl
{/* Shareable Link */} {/* Shareable Link */}
<div> <div>
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2"> <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> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <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" 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" /> <Share2 className="w-4 h-4" />
Share via... {t('inviteApplicantsModal.shareVia')}
</button> </button>
{/* Email Invite */} {/* Email Invite */}
<div> <div>
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2"> <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> </label>
<textarea <textarea
value={emailRecipients} value={emailRecipients}
onChange={(e) => setEmailRecipients(e.target.value)} 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" className="w-full min-h-[48px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
/> />
<button <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" 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" /> <Mail className="w-4 h-4" />
Send Email Invitations {t('inviteApplicantsModal.sendEmailInvitations')}
</button> </button>
</div> </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"> <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" /> <Smartphone className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<p className="text-[11px] text-[#374151] leading-relaxed"> <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> </p>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} className="w-full">Done</Button> <Button variant="outline" onClick={onClose} className="w-full">{t('inviteApplicantsModal.done')}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -1,6 +1,7 @@
import type { JobApplication } from '@/types/entities'; import type { JobApplication } from '@/types/entities';
import React from 'react'; import React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
const TIERS = [ const TIERS = [
{ label: 'Top Talent', range: '80-100', color: 'bg-[#1E3A8A]', textColor: 'text-[#1E3A8A]' }, { label: 'Top Talent', range: '80-100', color: 'bg-[#1E3A8A]', textColor: 'text-[#1E3A8A]' },
@@ -21,6 +22,7 @@ interface KROWCandidateLeaderboardProps {
} }
export default function KROWCandidateLeaderboard({ applications }: KROWCandidateLeaderboardProps) { export default function KROWCandidateLeaderboard({ applications }: KROWCandidateLeaderboardProps) {
const { t } = useTranslation();
const screened = applications.filter(a => a.ai_score > 0 || a.status !== 'applied'); 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 counts = TIERS.map((_, i) => screened.filter(a => getTier(a.ai_score || 0) === i).length);
const total = counts.reduce((a, b) => a + b, 0) || 1; const total = counts.reduce((a, b) => a + b, 0) || 1;
@@ -28,7 +30,7 @@ export default function KROWCandidateLeaderboard({ applications }: KROWCandidate
return ( return (
<div className="bg-white border border-[#E5E7EB] rounded-xl p-6"> <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 */} {/* Bar chart */}
<div className="space-y-3 mb-6"> <div className="space-y-3 mb-6">
@@ -70,7 +72,7 @@ export default function KROWCandidateLeaderboard({ applications }: KROWCandidate
</div> </div>
))} ))}
{sorted.length === 0 && ( {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>
</div> </div>

View File

@@ -3,6 +3,7 @@ import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { payLabel } from '@/lib/positionModel'; import { payLabel } from '@/lib/positionModel';
import { useTranslation } from 'react-i18next';
const STATUS_STYLES = { const STATUS_STYLES = {
active: 'bg-[#0838E0] text-white', active: 'bg-[#0838E0] text-white',
@@ -24,6 +25,7 @@ interface KROWJobPostingCardProps {
} }
export default function KROWJobPostingCard({ posting, stats }: KROWJobPostingCardProps) { export default function KROWJobPostingCard({ posting, stats }: KROWJobPostingCardProps) {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const applied = stats?.applied ?? 0; const applied = stats?.applied ?? 0;
const screened = stats?.screened ?? 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]"> <div className="flex items-center justify-between pt-3 border-t border-[#F3F4F6]">
{posting.ai_generated ? ( {posting.ai_generated ? (
<span className="px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium"> <span className="px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium">
AI Built {t('kROWJobPostingCard.aiBuilt')}
</span> </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>
</div> </div>
); );

View File

@@ -4,6 +4,7 @@ import { getTalentScore, getScoreBand, getGrowth } from '@/lib/talentHome';
import { ShieldCheck, Smartphone, Image as ImageIcon, QrCode, Sparkles } from 'lucide-react'; import { ShieldCheck, Smartphone, Image as ImageIcon, QrCode, Sparkles } from 'lucide-react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { Image } from '@/components/ui/image'; 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 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); const toFICO = (s) => 300 + Math.round((clamp(s) / 100) * 550);
@@ -34,9 +35,10 @@ function download(filename, content, type) {
} }
export default function KrowIdPreview() { export default function KrowIdPreview() {
const { t } = useTranslation();
const { data: profile, isLoading } = useWorkerProfile(); const { data: profile, isLoading } = useWorkerProfile();
if (isLoading || !profile) { 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); const score = getTalentScore(profile);
@@ -81,7 +83,7 @@ export default function KrowIdPreview() {
)} )}
</div> </div>
<div className="min-w-0 text-left"> <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> <h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight leading-tight truncate">{name}</h2>
<p className="text-[11px] text-[#6B7280] truncate">{title}</p> <p className="text-[11px] text-[#6B7280] truncate">{title}</p>
</div> </div>
@@ -116,18 +118,18 @@ export default function KrowIdPreview() {
{/* footer: QR + actions */} {/* footer: QR + actions */}
<div className="mt-4 flex items-center gap-3"> <div className="mt-4 flex items-center gap-3">
<div className="rounded-lg bg-white p-1 border border-[#E6ECF5] shadow-sm shrink-0"> <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>
<div className="flex-1 grid grid-cols-2 gap-1.5"> <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"> <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>
<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"> <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> </button>
</div> </div>
</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> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { Camera, Fingerprint, Sparkles } from 'lucide-react'; import { Camera, Fingerprint, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// A living identity card that reflects — rather than describes — the resume-less // 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. // 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) { export default function KrowIdentityCard({ form }: KrowIdentityCardProps) {
const { t } = useTranslation();
const parts = [ const parts = [
{ key: 'face', filled: !!form.selfie_url }, { key: 'face', filled: !!form.selfie_url },
{ key: 'name', filled: !!form.full_name.trim() }, { 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="relative flex items-center gap-4">
<div className="shrink-0"> <div className="shrink-0">
{form.selfie_url ? ( {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"> <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" /> <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> </div>
<div className="min-w-0 flex-1"> <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"> <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> </div>
<h1 className="text-[22px] sm:text-[26px] font-bold leading-tight mt-1.5 truncate"> <h1 className="text-[22px] sm:text-[26px] font-bold leading-tight mt-1.5 truncate">
{form.full_name.trim() || 'Your name'} {form.full_name.trim() || 'Your name'}
@@ -47,15 +49,15 @@ export default function KrowIdentityCard({ form }: KrowIdentityCardProps) {
</div> </div>
<div className="relative mt-4 space-y-2.5"> <div className="relative mt-4 space-y-2.5">
<SlotRow label="Skills" items={form.skills} /> <SlotRow label={t('krowIdentityCard.skills')} items={form.skills} />
<SlotRow label="Languages" items={form.languages} /> <SlotRow label={t('krowIdentityCard.languages')} items={form.languages} />
<SlotRow label="Availability" items={form.availability} /> <SlotRow label={t('krowIdentityCard.availability')} items={form.availability} />
<SlotRow label="Certifications" items={form.certifications} /> <SlotRow label={t('krowIdentityCard.certifications')} items={form.certifications} />
</div> </div>
<div className="relative mt-4"> <div className="relative mt-4">
<div className="flex items-center justify-between text-[11px] mb-1.5"> <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> <span className="opacity-70">{done}/{parts.length}</span>
</div> </div>
<div className="h-1.5 w-full rounded-full bg-white/15 overflow-hidden"> <div className="h-1.5 w-full rounded-full bg-white/15 overflow-hidden">

View File

@@ -8,6 +8,7 @@ import { LEVEL_LABEL, matchPosition, skillName } from '@/lib/skillGraph';
import { poolFor } from '@/lib/workforce'; import { poolFor } from '@/lib/workforce';
import { usePageSkills } from '@/lib/skills/usePageSkills'; import { usePageSkills } from '@/lib/skills/usePageSkills';
import { useApplications, useAssignments } from '@/lib/krowHooks'; import { useApplications, useAssignments } from '@/lib/krowHooks';
import { useTranslation } from 'react-i18next';
/** /**
* Who is qualified for this position, and who is one module away. * Who is qualified for this position, and who is one module away.
@@ -42,6 +43,7 @@ interface RequirementRowProps {
} }
function RequirementRow({ line }: RequirementRowProps) { function RequirementRow({ line }: RequirementRowProps) {
const { t } = useTranslation();
const Icon = line.met ? Check : line.close ? Minus : X; const Icon = line.met ? Check : line.close ? Minus : X;
const tone = line.met ? 'text-[#059669]' : line.close ? 'text-[#D97706]' : 'text-[#DC2626]'; 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" /> <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]"> <span className="min-w-0 flex-1 truncate text-[#374151]">
{line.name} — {line.heldLabel} {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>
<span className="shrink-0 tabular-nums text-[#9CA3AF]"> <span className="shrink-0 tabular-nums text-[#9CA3AF]">
{line.earned}/{line.weight} {line.earned}/{line.weight}
@@ -71,6 +73,7 @@ interface CandidateRowProps {
} }
function CandidateRow({ row, job, onView, onInterview, busy, attaches }: CandidateRowProps) { function CandidateRow({ row, job, onView, onInterview, busy, attaches }: CandidateRowProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
/* Identity is the candidate record; the worker profile is enrichment that /* Identity is the candidate record; the worker profile is enrichment that
most candidates do not have. Nothing here reads a profile field without 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> </ul>
) : ( ) : (
<p className="mt-3 text-[12px] text-[#6B7280]"> <p className="mt-3 text-[12px] text-[#6B7280]">
This position states no requirements that can be checked against this candidate&apos;s record. {t('matchedCandidates.thisPositionStatesNoRequirementsThat')}
</p> </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"> <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" /> <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]"> <p className="min-w-0 flex-1 text-[12px] text-[#1E3A8A]">
<span className="font-semibold">{recommendation.module.title}</span> would take{' '} <span className="font-semibold">{recommendation.module.title}</span> {t('matchedCandidates.wouldTake')}{' '}
{candidate.applicant_name.split(' ')[0]} to {recommendation.unlocks} — match{' '} {candidate.applicant_name.split(' ')[0]} to {recommendation.unlocks} {t('matchedCandidates.match')}{' '}
<span className="font-semibold tabular-nums">{recommendation.currentScore}% → {recommendation.projectedScore}%</span> <span className="font-semibold tabular-nums">{recommendation.currentScore}% → {recommendation.projectedScore}%</span>
</p> </p>
</div> </div>
@@ -166,7 +169,7 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }: Candida
onClick={() => onInterview(application)} 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" 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>
)} )}
<button <button
@@ -174,7 +177,7 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }: Candida
onClick={() => onView(row)} 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]" 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> </button>
</div> </div>
</div> </div>
@@ -182,13 +185,13 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }: Candida
{open && ( {open && (
<div className="mt-3 rounded-lg bg-[#F9FAFB] p-3"> <div className="mt-3 rounded-lg bg-[#F9FAFB] p-3">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-[#9CA3AF]"> <p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-[#9CA3AF]">
{job.title} — weighted requirements {job.title} {t('matchedCandidates.weightedRequirements')}
</p> </p>
<ul className="space-y-1 text-[12px] text-[#374151]"> <ul className="space-y-1 text-[12px] text-[#374151]">
{(match?.lines || []).map((line) => ( {(match?.lines || []).map((line) => (
<li key={line.skillId} className="flex items-baseline justify-between gap-3"> <li key={line.skillId} className="flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate"> <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>
<span className="shrink-0 tabular-nums font-medium"> <span className="shrink-0 tabular-nums font-medium">
{line.weight != null ? `${line.earned} of ${line.weight}` : line.met ? 'Met' : 'Not met'} {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> </ul>
<p className="mt-2 border-t border-[#E5E7EB] pt-2 text-[12px] font-semibold text-[#111827]"> <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> </p>
{match.gaps.length > 0 && ( {match.gaps.length > 0 && (
<p className="mt-1 text-[12px] text-[#B45309]">{match.gaps[0].note}</p> <p className="mt-1 text-[12px] text-[#B45309]">{match.gaps[0].note}</p>
)} )}
<p className="mt-1 text-[11px] text-[#9CA3AF]"> <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> </p>
</div> </div>
)} )}
@@ -221,6 +224,7 @@ interface MatchedCandidatesProps {
} }
export function MatchedCandidates({ job, profiles = [], courses = [], applications = [], onInterview, busy }: MatchedCandidatesProps) { export function MatchedCandidates({ job, profiles = [], courses = [], applications = [], onInterview, busy }: MatchedCandidatesProps) {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const positionSkills = usePageSkills('positions'); 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="rounded-2xl border border-[#E5E7EB] bg-white p-6">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0"> <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]"> <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 > 0
? `, ${counts.verified} scored against verified skills` ? `, ${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> </p>
</div> </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. */} {/* What the position asks for, so the scores below have a stated basis. */}
{!hasSkillRequirements && ( {!hasSkillRequirements && (
<p className="mt-4 rounded-lg border border-dashed border-[#E5E7EB] px-3 py-2 text-[12px] text-[#6B7280]"> <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 {t('matchedCandidates.thisPositionDefinesNoVerifiedSkill')}
experience, English level and certifications it states. Add skill requirements by editing
the position to score against verified training.
</p> </p>
)} )}
{hasSkillRequirements && ( {hasSkillRequirements && (
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg bg-[#F9FAFB] px-3 py-2"> <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" /> <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) => ( {(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]"> <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]} {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. * scoring function that cannot drift between them.
*/ */
export function YourSkillFit({ job, held, className = '' }) { export function YourSkillFit({ job, held, className = '' }) {
const { t } = useTranslation();
const match = matchPosition(job, held); const match = matchPosition(job, held);
if (!match) return null; if (!match) return null;
const tone = TONE[match.tone] || TONE.warning; const tone = TONE[match.tone] || TONE.warning;
@@ -368,7 +371,7 @@ export function YourSkillFit({ job, held, className = '' }) {
return ( return (
<div className={cn('rounded-2xl border border-[#E5E7EB] bg-white p-6', className)}> <div className={cn('rounded-2xl border border-[#E5E7EB] bg-white p-6', className)}>
<div className="flex flex-wrap items-center justify-between gap-3"> <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)}> <span className={cn('rounded-full border px-2.5 py-1 text-[12px] font-semibold', tone.chip)}>
{match.score}% · {match.band} {match.score}% · {match.band}
</span> </span>

View File

@@ -3,6 +3,7 @@ import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Mail, Phone, Send, CheckCircle2 } from 'lucide-react'; import { Mail, Phone, Send, CheckCircle2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface MessageCandidateModalProps { interface MessageCandidateModalProps {
open: boolean; open: boolean;
@@ -13,6 +14,7 @@ interface MessageCandidateModalProps {
} }
export default function MessageCandidateModal({ open, onClose, application }: MessageCandidateModalProps) { export default function MessageCandidateModal({ open, onClose, application }: MessageCandidateModalProps) {
const { t } = useTranslation();
const [channel, setChannel] = useState('message'); const [channel, setChannel] = useState('message');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
@@ -30,7 +32,7 @@ export default function MessageCandidateModal({ open, onClose, application }: Me
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Message {application?.applicant_name}</DialogTitle> <DialogTitle>{t('messageCandidateModal.message')} {application?.applicant_name}</DialogTitle>
</DialogHeader> </DialogHeader>
{sent ? ( {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"> <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]" /> <CheckCircle2 className="w-6 h-6 text-[#059669]" />
</div> </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>
) : ( ) : (
<div className="space-y-4"> <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]' 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>
<button <button
onClick={() => setChannel('call')} 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]' 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> </button>
</div> </div>
{channel === 'call' ? ( {channel === 'call' ? (
<div className="py-6 text-center text-[13px] text-[#6B7280]"> <div className="py-6 text-center text-[13px] text-[#6B7280]">
<Phone className="w-8 h-8 text-[#0838E0] mx-auto mb-2" /> <Phone className="w-8 h-8 text-[#0838E0] mx-auto mb-2" />
Calling {application?.phone || 'candidate'}... {t('messageCandidateModal.calling')} {application?.phone || 'candidate'}...
</div> </div>
) : ( ) : (
<textarea <textarea
@@ -76,9 +78,9 @@ export default function MessageCandidateModal({ open, onClose, application }: Me
)} )}
<DialogFooter> <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]"> <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> </Button>
</DialogFooter> </DialogFooter>
</div> </div>

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
const OWLIVER_URL = '/owliver-avatar.png'; 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) { export default function OwliverAvatar({ className = 'w-16 h-16', rounded = 'rounded-full' }: OwliverAvatarProps) {
const { t } = useTranslation();
return ( return (
<img <img
src={OWLIVER_URL} src={OWLIVER_URL}
alt="Owliver" alt={t('owliverAvatar.owliver')}
className={`shrink-0 overflow-hidden object-cover ${rounded} ${className}`} className={`shrink-0 overflow-hidden object-cover ${rounded} ${className}`}
/> />
); );

View File

@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { Image } from '@/components/ui/image'; import { Image } from '@/components/ui/image';
import { Sparkles, X } from 'lucide-react'; import { Sparkles, X } from 'lucide-react';
import Owliver from '@/pages/Owliver'; import Owliver from '@/pages/Owliver';
import { useTranslation } from 'react-i18next';
const OWLIVER_URL = '/owliver-avatar.png'; const OWLIVER_URL = '/owliver-avatar.png';
@@ -13,6 +14,7 @@ interface OwliverChatBubbleProps {
} }
export default function OwliverChatBubble({ open: externalOpen, onOpenChange, showTrigger = true }: OwliverChatBubbleProps) { export default function OwliverChatBubble({ open: externalOpen, onOpenChange, showTrigger = true }: OwliverChatBubbleProps) {
const { t } = useTranslation();
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const open = externalOpen !== undefined ? externalOpen : internalOpen; const open = externalOpen !== undefined ? externalOpen : internalOpen;
const setOpen = (v) => { const setOpen = (v) => {
@@ -31,17 +33,17 @@ export default function OwliverChatBubble({ open: externalOpen, onOpenChange, sh
exit={{ opacity: 0, y: 12, scale: 0.96 }} exit={{ opacity: 0, y: 12, scale: 0.96 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
onClick={() => setOpen(true)} 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)] 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)] 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]" ring-1 ring-black/5 transition-transform duration-300 hover:scale-[1.01]"
> >
<Sparkles className="w-4 h-4 text-[#0838E0] shrink-0" /> <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"> <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>
<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"> <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> </span>
</motion.button> </motion.button>
) : open ? ( ) : 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"> <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"> <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> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-[14px] font-bold leading-tight flex items-center gap-1.5"> <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>
<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> </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" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>

View File

@@ -1,6 +1,7 @@
import type { JobApplication } from '@/types/entities'; import type { JobApplication } from '@/types/entities';
import React from 'react'; import React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
const STAGES = [ const STAGES = [
{ key: 'applied', label: 'Applied', color: 'bg-[#D1E0D7]', text: 'text-[#333F48]' }, { key: 'applied', label: 'Applied', color: 'bg-[#D1E0D7]', text: 'text-[#333F48]' },
@@ -15,6 +16,7 @@ interface PipelineFunnelProps {
} }
export default function PipelineFunnel({ applications }: PipelineFunnelProps) { export default function PipelineFunnel({ applications }: PipelineFunnelProps) {
const { t } = useTranslation();
const counts = STAGES.map((s) => { const counts = STAGES.map((s) => {
if (s.key === 'ai_screened') { if (s.key === 'ai_screened') {
return applications.filter(a => ['ai_screened', 'shortlisted', 'interview', 'hired'].includes(a.status)).length; return applications.filter(a => ['ai_screened', 'shortlisted', 'interview', 'hired'].includes(a.status)).length;
@@ -33,8 +35,8 @@ export default function PipelineFunnel({ applications }: PipelineFunnelProps) {
return ( return (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm"> <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> <h3 className="font-heading font-semibold text-[#111827] text-[16px] mb-1">{t('pipelineFunnel.pipelineStages')}</h3>
<p className="text-[12px] text-[#6B7280] mb-5">Candidate progression through hiring funnel</p> <p className="text-[12px] text-[#6B7280] mb-5">{t('pipelineFunnel.candidateProgressionThroughHiringFunnel')}</p>
<div className="space-y-4"> <div className="space-y-4">
{STAGES.map((stage, i) => { {STAGES.map((stage, i) => {
@@ -48,11 +50,11 @@ export default function PipelineFunnel({ applications }: PipelineFunnelProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[#374151]">{stage.label}</span> <span className="text-[13px] font-medium text-[#374151]">{stage.label}</span>
{i > 0 && stepRate <= 100 && ( {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>
<div className="flex items-center gap-3"> <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> <span className="text-[15px] font-bold text-[#111827] w-8 text-right">{count}</span>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import type { JobPosting } from '@/types/entities'; import type { JobPosting } from '@/types/entities';
import React from 'react'; import React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { import {
customRequirementsText, englishLabel, experienceLabel, rateLabel, statedRequirements, customRequirementsText, englishLabel, experienceLabel, rateLabel, statedRequirements,
} from '@/lib/positionModel'; } 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. * sit side by side rather than beside a gap where a third would have gone.
*/ */
export function PositionRequirements({ position, className = '' }) { export function PositionRequirements({ position, className = '' }) {
const { t } = useTranslation();
const fields = statedRequirements(position); const fields = statedRequirements(position);
const certifications = position.certifications_required || []; 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. */} single values above. Shown only when the position asks for any. */}
{certifications.length > 0 && ( {certifications.length > 0 && (
<div className="rounded-xl border border-border bg-surface p-3.5 sm:col-span-2"> <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"> <div className="mt-1.5 flex flex-wrap gap-1.5">
{certifications.map((cert) => ( {certifications.map((cert) => (
<span <span

View File

@@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Star, BadgeCheck, X, Award } from 'lucide-react'; import { Star, BadgeCheck, X, Award } from 'lucide-react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useUpdateStaff } from '@/lib/krowHooks'; import { useUpdateStaff } from '@/lib/krowHooks';
import { useTranslation } from 'react-i18next';
const SKILL_SUGGESTIONS = [ const SKILL_SUGGESTIONS = [
'Punctuality', 'Teamwork', 'Communication', 'Reliability', 'Punctuality', 'Teamwork', 'Communication', 'Reliability',
@@ -19,6 +20,7 @@ interface RateEndorseModalProps {
} }
export default function RateEndorseModal({ open, onClose, staff, reviewerName }: RateEndorseModalProps) { export default function RateEndorseModal({ open, onClose, staff, reviewerName }: RateEndorseModalProps) {
const { t } = useTranslation();
const update = useUpdateStaff(); const update = useUpdateStaff();
const [rating, setRating] = useState(staff?.client_rating || 0); const [rating, setRating] = useState(staff?.client_rating || 0);
const [hover, setHover] = useState(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()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md p-0"> <DialogContent className="max-w-md p-0">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>Rate &amp; Endorse {staff.name}</DialogTitle> <DialogTitle>{t('rateEndorseModal.rateEndorse')}orse {staff.name}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="p-5 space-y-5"> <div className="p-5 space-y-5">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">Rate &amp; 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> <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> <p className="text-[12px] text-[#6B7280] mt-0.5">{staff.role || 'Staff'} {staff.profile_tier && `· ${staff.profile_tier}`}</p>
</div> </div>
@@ -82,7 +84,7 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName }:
{/* Star rating */} {/* Star rating */}
<div> <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"> <div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => ( {[1, 2, 3, 4, 5].map((n) => (
<button <button
@@ -104,19 +106,19 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName }:
{/* Endorsement text */} {/* Endorsement text */}
<div> <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 <textarea
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
rows={3} 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" 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> </div>
{/* Endorsed skills */} {/* Endorsed skills */}
<div> <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"> <div className="flex flex-wrap gap-1.5">
{SKILL_SUGGESTIONS.map((s) => { {SKILL_SUGGESTIONS.map((s) => {
const on = skills.includes(s); const on = skills.includes(s);
@@ -144,7 +146,7 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName }:
onClick={onClose} onClick={onClose}
className="px-4 py-2 rounded-lg border border-[#E5E7EB] text-[#374151] text-[13px] font-medium hover:bg-[#F9FAFB]" 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>
<button <button
onClick={submit} onClick={submit}

View File

@@ -1,6 +1,7 @@
import type { JobApplication } from '@/types/entities'; import type { JobApplication } from '@/types/entities';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Quote, BadgeCheck, Star, ChevronDown } from 'lucide-react'; import { Quote, BadgeCheck, Star, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const COMPANY_COLORS = ['bg-[#0838E0]', 'bg-[#333F48]', 'bg-[#062BAF]']; const COMPANY_COLORS = ['bg-[#0838E0]', 'bg-[#333F48]', 'bg-[#062BAF]'];
@@ -57,6 +58,7 @@ interface ReferencesEndorsementsProps {
} }
export default function ReferencesEndorsements({ application }: ReferencesEndorsementsProps) { export default function ReferencesEndorsements({ application }: ReferencesEndorsementsProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const voices = buildVoices(application); const voices = buildVoices(application);
/* `ai_strengths` is `jsonb` (typed `unknown`) while `skills` is a real /* `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> <p className="text-[12px] text-[#374151] mt-2 leading-relaxed">"{v.quote}"</p>
<div className="flex items-center gap-2 mt-2.5"> <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"> <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>
<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"> <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> </button>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import type { Staff } from '@/types/entities'; import type { Staff } from '@/types/entities';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { TrendingUp, TrendingDown, Clock, Activity } from 'lucide-react'; import { TrendingUp, TrendingDown, Clock, Activity } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MS_PER_DAY = 1000 * 60 * 60 * 24; const MS_PER_DAY = 1000 * 60 * 60 * 24;
@@ -16,6 +17,7 @@ interface RetentionMetricsProps {
} }
export default function RetentionMetrics({ staff }: RetentionMetricsProps) { export default function RetentionMetrics({ staff }: RetentionMetricsProps) {
const { t } = useTranslation();
const stats = useMemo(() => { const stats = useMemo(() => {
const total = staff.length; const total = staff.length;
const active = staff.filter((s) => s.status === 'active').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"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Metric <Metric
icon={<TrendingUp className="w-4 h-4" />} icon={<TrendingUp className="w-4 h-4" />}
label="Retention Rate" label={t('retentionMetrics.retentionRate')}
value={stats.retentionRate == null ? '—' : `${stats.retentionRate}%`} value={stats.retentionRate == null ? '—' : `${stats.retentionRate}%`}
tone={stats.retentionRate == null ? 'neutral' : stats.retentionRate >= 70 ? 'good' : stats.retentionRate >= 40 ? 'warn' : 'bad'} tone={stats.retentionRate == null ? 'neutral' : stats.retentionRate >= 70 ? 'good' : stats.retentionRate >= 40 ? 'warn' : 'bad'}
sub={`${stats.active} active · ${stats.churned} churned`} sub={`${stats.active} active · ${stats.churned} churned`}
/> />
<Metric <Metric
icon={<Activity className="w-4 h-4" />} icon={<Activity className="w-4 h-4" />}
label="Still Active" label={t('retentionMetrics.stillActive')}
value={stats.active} value={stats.active}
tone="good" tone="good"
sub={stats.onboarding > 0 ? `+ ${stats.onboarding} onboarding` : 'On track'} sub={stats.onboarding > 0 ? `+ ${stats.onboarding} onboarding` : 'On track'}
/> />
<Metric <Metric
icon={<TrendingDown className="w-4 h-4" />} icon={<TrendingDown className="w-4 h-4" />}
label="Churned" label={t('retentionMetrics.churned')}
value={stats.churned} value={stats.churned}
tone={stats.churned === 0 ? 'good' : 'bad'} tone={stats.churned === 0 ? 'good' : 'bad'}
sub={`${stats.total} total hires`} sub={`${stats.total} total hires`}
/> />
<Metric <Metric
icon={<Clock className="w-4 h-4" />} icon={<Clock className="w-4 h-4" />}
label="Avg Tenure" label={t('retentionMetrics.avgTenure')}
value={stats.avgTenure > 0 ? `${stats.avgTenure}d` : '—'} value={stats.avgTenure > 0 ? `${stats.avgTenure}d` : '—'}
tone="neutral" tone="neutral"
sub={`30d: ${stats.c30 ?? '—'} · 90d: ${stats.c90 ?? '—'}`} sub={`30d: ${stats.c30 ?? '—'} · 90d: ${stats.c90 ?? '—'}`}

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Calendar, CalendarX, Check, Clock, Phone, User, UserX, Video } from 'lucide-react'; import { Calendar, CalendarX, Check, Clock, Phone, User, UserX, Video } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { import {
useCompleteHumanInterview, useInterviews, useRecordInterviewNotHeld, useCompleteHumanInterview, useInterviews, useRecordInterviewNotHeld,
useScheduleHumanInterview, useUserActivity, useScheduleHumanInterview, useUserActivity,
@@ -70,6 +71,7 @@ interface ScheduleInterviewModalProps {
} }
export default function ScheduleInterviewModal({ open, onClose, application, position }: ScheduleInterviewModalProps) { export default function ScheduleInterviewModal({ open, onClose, application, position }: ScheduleInterviewModalProps) {
const { t } = useTranslation();
const { data: activity = [] } = useUserActivity(); const { data: activity = [] } = useUserActivity();
const { data: interviews = [] } = useInterviews(); 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"> <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]" /> <Check className="w-6 h-6 text-[#0838E0]" />
</div> </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"> <p className="text-[12px] text-[#6B7280] mt-1">
{application?.applicant_name} is awaiting a hiring decision. {application?.applicant_name} {t('scheduleInterviewModal.isAwaitingAHiringDecision')}
</p> </p>
<DialogFooter className="mt-5"> <DialogFooter className="mt-5">
<Button variant="outline" onClick={onClose}>Close</Button> <Button variant="outline" onClick={onClose}>{t('scheduleInterviewModal.close')}</Button>
</DialogFooter> </DialogFooter>
</div> </div>
) : state === 'scheduled' && !recording ? ( ) : state === 'scheduled' && !recording ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] p-3"> <div className="rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] p-3">
<p className="text-[13px] font-medium text-[#111827]"> <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>
<p className="text-[12px] text-[#6B7280] mt-0.5"> <p className="text-[12px] text-[#6B7280] mt-0.5">
{booked?.date} at {booked?.time} {booked?.date} at {booked?.time}
@@ -184,9 +186,9 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
</div> </div>
<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"> <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> </p>
<div className="grid gap-2 mt-2.5"> <div className="grid gap-2 mt-2.5">
<button <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" 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" /> <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>
<button <button
onClick={() => setRecording(NO_SHOW)} 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" 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" /> <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>
<button <button
onClick={() => setRecording(CANCELLED)} 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" 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" /> <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> </button>
</div> </div>
</div> </div>
@@ -216,17 +218,16 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
{error && <p className="text-[12px] text-[#B91C1C]">{error}</p>} {error && <p className="text-[12px] text-[#B91C1C]">{error}</p>}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>Close</Button> <Button variant="outline" onClick={onClose}>{t('scheduleInterviewModal.close')}</Button>
</DialogFooter> </DialogFooter>
</div> </div>
) : recording ? ( ) : recording ? (
<div className="space-y-4"> <div className="space-y-4">
{recording === 'completed' ? ( {recording === 'completed' ? (
<div> <div>
<Label className="text-[13px] text-[#374151]">Interviewer&apos;s assessment (optional)</Label> <Label className="text-[13px] text-[#374151]">{t('scheduleInterviewModal.interviewersAssessmentOptional')}</Label>
<p className="text-[12px] text-[#6B7280] mt-0.5"> <p className="text-[12px] text-[#6B7280] mt-0.5">
Your own reading, entered by you. It is recorded alongside the interview {t('scheduleInterviewModal.yourOwnReadingEnteredByYou')}
and never decides anything — hiring stays a separate, deliberate action.
</p> </p>
<div className="grid grid-cols-3 gap-2 mt-1.5"> <div className="grid grid-cols-3 gap-2 mt-1.5">
{ASSESSMENTS.map((a) => ( {ASSESSMENTS.map((a) => (
@@ -253,7 +254,7 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
)} )}
<div> <div>
<Label className="text-[13px] text-[#374151]">Notes (optional)</Label> <Label className="text-[13px] text-[#374151]">{t('scheduleInterviewModal.notesOptional')}</Label>
<textarea <textarea
value={outcomeNotes} value={outcomeNotes}
onChange={(e) => setOutcomeNotes(e.target.value)} onChange={(e) => setOutcomeNotes(e.target.value)}
@@ -266,7 +267,7 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => { setRecording(null); setError(''); }} disabled={busy}> <Button variant="outline" onClick={() => { setRecording(null); setError(''); }} disabled={busy}>
Back {t('scheduleInterviewModal.back')}
</Button> </Button>
<Button onClick={submitOutcome} disabled={busy} className="bg-[#0838E0] hover:bg-[#062BAF]"> <Button onClick={submitOutcome} disabled={busy} className="bg-[#0838E0] hover:bg-[#062BAF]">
{recording === 'completed' ? 'Record interview' : 'Save'} {recording === 'completed' ? 'Record interview' : 'Save'}
@@ -284,7 +285,7 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
)} )}
<div> <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"> <div className="grid grid-cols-3 gap-2 mt-1.5">
{TYPES.map((opt) => ( {TYPES.map((opt) => (
<button <button
@@ -303,21 +304,21 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <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" /> <Input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="mt-1.5" />
</div> </div>
<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" /> <Input type="time" value={time} onChange={(e) => setTime(e.target.value)} className="mt-1.5" />
</div> </div>
</div> </div>
<div> <div>
<Label className="text-[13px] text-[#374151]">Notes (optional)</Label> <Label className="text-[13px] text-[#374151]">{t('scheduleInterviewModal.notesOptional')}</Label>
<textarea <textarea
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
placeholder="Add any prep notes..." placeholder={t('scheduleInterviewModal.addAnyPrepNotes')}
className={field} className={field}
/> />
</div> </div>
@@ -326,15 +327,15 @@ export default function ScheduleInterviewModal({ open, onClose, application, pos
truth: booking records the booking, and nothing more. */} truth: booking records the booking, and nothing more. */}
<p className="text-[12px] text-[#6B7280]"> <p className="text-[12px] text-[#6B7280]">
<Calendar className="w-3 h-3 inline-block mr-1 -mt-0.5" /> <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> </p>
{error && <p className="text-[12px] text-[#B91C1C]">{error}</p>} {error && <p className="text-[12px] text-[#B91C1C]">{error}</p>}
<DialogFooter> <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]"> <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> </Button>
</DialogFooter> </DialogFooter>
</div> </div>

View File

@@ -1,6 +1,7 @@
import type { JobApplication } from '@/types/entities'; import type { JobApplication } from '@/types/entities';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts'; import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
import { useTranslation } from 'react-i18next';
const TIERS = [ const TIERS = [
{ label: '0-39', range: [0, 39], color: '#333F48' }, { label: '0-39', range: [0, 39], color: '#333F48' },
@@ -14,6 +15,7 @@ interface ScoreDistributionProps {
} }
export default function ScoreDistribution({ applications }: ScoreDistributionProps) { export default function ScoreDistribution({ applications }: ScoreDistributionProps) {
const { t } = useTranslation();
const data = useMemo(() => { const data = useMemo(() => {
const scored = applications.filter(a => a.ai_score > 0); const scored = applications.filter(a => a.ai_score > 0);
return TIERS.map((tier) => ({ return TIERS.map((tier) => ({
@@ -37,10 +39,10 @@ export default function ScoreDistribution({ applications }: ScoreDistributionPro
return ( return (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm"> <div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
<div className="flex items-center justify-between mb-1"> <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-right">
<div className="text-[22px] font-heading font-bold gradient-text">{avgScore}</div> <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>
</div> </div>
<p className="text-[12px] text-[#6B7280] mb-5"> <p className="text-[12px] text-[#6B7280] mb-5">

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { LEVEL_LABEL, SKILLS, skillById } from '@/lib/skillGraph'; 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. * 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) { export function SkillRequirementsField({ value = [], onChange }: SkillRequirementsFieldProps) {
const { t } = useTranslation();
const rows = Array.isArray(value) ? value : []; const rows = Array.isArray(value) ? value : [];
const used = new Set(rows.map((r) => r.skill_id)); const used = new Set(rows.map((r) => r.skill_id));
const available = SKILLS.filter((s) => !used.has(s.id)); const available = SKILLS.filter((s) => !used.has(s.id));
@@ -46,19 +48,19 @@ export function SkillRequirementsField({ value = [], onChange }: SkillRequiremen
<div> <div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<label className="text-[13px] font-medium text-[#374151]"> <label className="text-[13px] font-medium text-[#374151]">
Required Skills {t('skillRequirementsField.requiredSkills')}
<span className="ml-1.5 font-normal text-[#6B7280]">— what a candidate must have verified in KROW Forge</span> <span className="ml-1.5 font-normal text-[#6B7280]">{t('skillRequirementsField.whatACandidateMustHaveVerified')}</span>
</label> </label>
{rows.length > 0 && ( {rows.length > 0 && (
<span className={`text-[12px] font-medium ${total === 100 ? 'text-[#059669]' : 'text-[#6B7280]'}`}> <span className={`text-[12px] font-medium ${total === 100 ? 'text-[#059669]' : 'text-[#6B7280]'}`}>
{total}% weighted {total}{t('skillRequirementsField.weighted')}
</span> </span>
)} )}
</div> </div>
{rows.length === 0 ? ( {rows.length === 0 ? (
<p className="mt-1.5 text-[12px] text-[#6B7280]"> <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> </p>
) : ( ) : (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
@@ -88,7 +90,7 @@ export function SkillRequirementsField({ value = [], onChange }: SkillRequiremen
<select <select
value={row.level} value={row.level}
onChange={(e) => update(i, { level: e.target.value })} 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" 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>)} {levels.map((l) => <option key={l} value={l}>{LEVEL_LABEL[l]}</option>)}
@@ -127,7 +129,7 @@ export function SkillRequirementsField({ value = [], onChange }: SkillRequiremen
onClick={add} 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]" 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> </button>
)} )}
</div> </div>

View File

@@ -5,6 +5,7 @@ import { Sparkles, Loader2, Briefcase, Star } from 'lucide-react';
import { Image } from '@/components/ui/image'; import { Image } from '@/components/ui/image';
import { getScoreBand, toFICO } from '@/lib/talentHome'; import { getScoreBand, toFICO } from '@/lib/talentHome';
import TalentDetailModal from '@/components/krow/TalentDetailModal'; import TalentDetailModal from '@/components/krow/TalentDetailModal';
import { useTranslation } from 'react-i18next';
const PORTRAIT_PHOTOS = [ const PORTRAIT_PHOTOS = [
'photo-1500648767731-7d3514ad8b31', 'photo-1500648767731-7d3514ad8b31',
@@ -40,6 +41,7 @@ interface SuggestedTalentProps {
} }
export default function SuggestedTalent({ job, onContact, onSchedule, onHire }: SuggestedTalentProps) { export default function SuggestedTalent({ job, onContact, onSchedule, onHire }: SuggestedTalentProps) {
const { t } = useTranslation();
const { data: profiles = [] } = useWorkerProfiles(); const { data: profiles = [] } = useWorkerProfiles();
const matchTalent = useMatchTalentForJob(); const matchTalent = useMatchTalentForJob();
const [matches, setMatches] = useState([]); 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"> <div className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-[#0838E0]" /> <Sparkles className="w-4 h-4 text-[#0838E0]" />
</div> </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> </div>
{matches.length > 0 && ( {matches.length > 0 && (
<button onClick={runMatch} disabled={matchTalent.isPending} className="text-[12px] font-medium text-[#0838E0] hover:underline disabled:opacity-50"> <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> </button>
)} )}
</div> </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 ? ( {matchTalent.isPending && matches.length === 0 ? (
<div className="flex items-center justify-center py-10 gap-2 text-[#6B7280]"> <div className="flex items-center justify-center py-10 gap-2 text-[#6B7280]">
<Loader2 className="w-5 h-5 animate-spin" /> <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> </div>
) : enriched.length === 0 ? ( ) : enriched.length === 0 ? (
<div className="text-center py-10"> <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>
) : ( ) : (
<div className="space-y-3"> <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} <Briefcase className="w-3 h-3" />{p.desired_position || p.current_position}
</span> </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"> <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> </span>
</div> </div>
{m.reasons?.length > 0 && ( {m.reasons?.length > 0 && (

View File

@@ -6,6 +6,7 @@ import { Image } from '@/components/ui/image';
import CareerScoreGauge from '@/components/krow/talent/CareerScoreGauge'; import CareerScoreGauge from '@/components/krow/talent/CareerScoreGauge';
import ExperienceEndorsements, { verifiedExperience } from '@/components/krow/talent/ExperienceEndorsements'; import ExperienceEndorsements, { verifiedExperience } from '@/components/krow/talent/ExperienceEndorsements';
import EliteTalentScore from '@/components/krow/talent/EliteTalentScore'; import EliteTalentScore from '@/components/krow/talent/EliteTalentScore';
import { useTranslation } from 'react-i18next';
function getInitials(name) { function getInitials(name) {
return (name || '').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??'; 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) { export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }: TalentDetailModalProps) {
const { t } = useTranslation();
if (!profile) return null; if (!profile) return null;
const badges = profile.earned_badges || []; const badges = profile.earned_badges || [];
const capabilities = (profile.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending'); 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()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[calc(100dvh-2rem)] overflow-y-auto p-0"> <DialogContent className="max-w-2xl max-h-[calc(100dvh-2rem)] overflow-y-auto p-0">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>{profile.full_name} — KROW Identity</DialogTitle> <DialogTitle>{profile.full_name} {t('talentDetailModal.krowIdentity')}</DialogTitle>
</DialogHeader> </DialogHeader>
{/* KROW ID header — clean white, no blue band */} {/* KROW ID header — clean white, no blue band */}
@@ -122,7 +124,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
</div> </div>
<div className="min-w-0 flex-1 pt-0.5"> <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"> <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>
<div className="flex items-center gap-2 mt-1 flex-wrap"> <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> <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> <span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${identityClass}`}>{identityTag}</span>
)} )}
{openToWork && ( {openToWork && (
<span className="text-[12px] text-[#6B7280]">· Open to work</span> <span className="text-[12px] text-[#6B7280]">{t('talentDetailModal.openToWork')}</span>
)} )}
</div> </div>
<p className="text-[11px] text-[#6B7280] mt-1"> <p className="text-[11px] text-[#6B7280] mt-1">
Career Report · {reportDate} {t('talentDetailModal.careerReport')} {reportDate}
</p> </p>
</div> </div>
</div> </div>
@@ -144,16 +146,16 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
{/* Quick stats banner — pill capsules, single line */} {/* Quick stats banner — pill capsules, single line */}
<div className="flex flex-nowrap gap-1.5 overflow-x-auto no-scrollbar"> <div className="flex flex-nowrap gap-1.5 overflow-x-auto no-scrollbar">
{profile.experience_years > 0 && ( {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 && ( {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) && ( {(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 && ( {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> </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="rounded-xl border border-[#DBEAFE] bg-[#EEF3FE] p-4">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<Sparkles className="w-4 h-4 text-[#0838E0]" /> <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> </div>
{matchReasons?.length > 0 && ( {matchReasons?.length > 0 && (
<ul className="text-[12px] text-[#374151] list-disc list-inside space-y-0.5"> <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 */} {/* Endorsements / Verified Capabilities */}
{capabilities.length > 0 && ( {capabilities.length > 0 && (
<Section label="VERIFIED CAPABILITIES (ENDORSEMENTS)"> <Section label={t('talentDetailModal.verifiedCapabilitiesEndorsements')}>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{capabilities.map((c, i) => ( {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]'}`}> <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 */} {/* Consolidated Experience & Endorsements — jobs + client endorsements in one list */}
{verifiedExperience(profile).length > 0 && ( {verifiedExperience(profile).length > 0 && (
<Section label="EXPERIENCE & ENDORSEMENTS"> <Section label={t('talentDetailModal.experienceEndorsements')}>
<ExperienceEndorsements profile={profile} /> <ExperienceEndorsements profile={profile} />
</Section> </Section>
)} )}
@@ -255,7 +257,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
<MessageSquare className="w-3.5 h-3.5 text-[#0838E0]" /> <MessageSquare className="w-3.5 h-3.5 text-[#0838E0]" />
</span> </span>
<div className="min-w-0"> <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> <p className="text-[12px] text-[#374151] leading-relaxed">{profile.communication_style}</p>
</div> </div>
</div> </div>
@@ -279,17 +281,17 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{onContact && ( {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"> <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> </button>
)} )}
{onSchedule && ( {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"> <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> </button>
)} )}
{onHire && ( {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"> <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> </button>
)} )}
</div> </div>

View File

@@ -5,6 +5,7 @@ import { Mail, Briefcase, MapPin, Star, BadgeCheck, Award, ChevronDown } from 'l
import { Image } from '@/components/ui/image'; import { Image } from '@/components/ui/image';
import EmployerInsights from '@/components/krow/talent/EmployerInsights'; import EmployerInsights from '@/components/krow/talent/EmployerInsights';
import { getScoreBand, toFICO } from '@/lib/talentHome'; import { getScoreBand, toFICO } from '@/lib/talentHome';
import { useTranslation } from 'react-i18next';
const PORTRAIT_PHOTOS = [ const PORTRAIT_PHOTOS = [
'photo-1500648767731-7d3514ad8b31', 'photo-1500648767731-7d3514ad8b31',
@@ -32,6 +33,7 @@ interface CareerGaugeProps {
} }
function CareerGauge({ fico }: CareerGaugeProps) { function CareerGauge({ fico }: CareerGaugeProps) {
const { t } = useTranslation();
const MIN = 300; const MIN = 300;
const MAX = 850; const MAX = 850;
const value = Math.max(MIN, Math.min(MAX, fico || MIN)); const value = Math.max(MIN, Math.min(MAX, fico || MIN));
@@ -45,7 +47,7 @@ function CareerGauge({ fico }: CareerGaugeProps) {
const color = value > MIN ? '#0838E0' : '#CBD5E1'; const color = value > MIN ? '#0838E0' : '#CBD5E1';
return ( return (
<div className="flex flex-col items-center shrink-0"> <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"> <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" /> <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 && ( {value > MIN && (
@@ -75,6 +77,7 @@ interface TalentPoolCardProps {
} }
export default function TalentPoolCard({ profile, onContact, onView }: TalentPoolCardProps) { export default function TalentPoolCard({ profile, onContact, onView }: TalentPoolCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const score = profile.krow_score || 0; const score = profile.krow_score || 0;
@@ -149,7 +152,7 @@ export default function TalentPoolCard({ profile, onContact, onView }: TalentPoo
</span> </span>
)} )}
{profile.experience_years > 0 && ( {profile.experience_years > 0 && (
<span>{profile.experience_years}yr exp</span> <span>{profile.experience_years}{t('talentPoolCard.yrExp')}</span>
)} )}
{profile.location && ( {profile.location && (
<span className="inline-flex items-center gap-1"> <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" 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" /> <Mail className="w-3.5 h-3.5" />
Contact {t('talentPoolCard.contact')}
</button> </button>
)} )}
<button <button

View File

@@ -3,6 +3,7 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Lock, ShieldCheck, Zap, Clock, ArrowRight } from 'lucide-react'; import { Lock, ShieldCheck, Zap, Clock, ArrowRight } from 'lucide-react';
import { isUnlocked } from '@/lib/provingGround'; import { isUnlocked } from '@/lib/provingGround';
import { useTranslation } from 'react-i18next';
const DIFF_STYLES = { const DIFF_STYLES = {
beginner: { label: 'Beginner', bg: 'bg-[#F0FDF4]', text: 'text-[#16A34A]', border: 'border-[#DCFCE7]' }, 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) { export default function UniversityCourseCard({ course, completed = false, profile }: UniversityCourseCardProps) {
const { t } = useTranslation();
const lock = isUnlocked(course, profile); const lock = isUnlocked(course, profile);
const diff = DIFF_STYLES[course.difficulty] || DIFF_STYLES.beginner; const diff = DIFF_STYLES[course.difficulty] || DIFF_STYLES.beginner;
const proofSkill = course.proof_skill || course.category || 'Workforce Skill'; 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"> <div className="shrink-0">
{completed ? ( {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]"> <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> </span>
) : !lock.unlocked ? ( ) : !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]"> <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>
) : ( ) : (
<span className={`inline-flex items-center text-[10px] font-bold px-2 py-0.5 rounded-full border ${diff.bg} ${diff.text} ${diff.border}`}> <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 */} {/* Requirements / Locked Reason */}
{!lock.unlocked && lock.reasons?.length > 0 && ( {!lock.unlocked && lock.reasons?.length > 0 && (
<div className="mt-2.5 p-2 rounded-lg bg-[#F8FAFC] border border-[#E2E8F0] text-[11px] text-[#64748B]"> <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>
)} )}
</div> </div>

View File

@@ -8,6 +8,7 @@ import {
levelLabel, matchingPositions, profileForEmail, levelLabel, matchingPositions, profileForEmail,
} from '@/lib/skillGraph'; } from '@/lib/skillGraph';
import { skillStatesForPage } from '@/lib/skills/pageSkills'; import { skillStatesForPage } from '@/lib/skills/pageSkills';
import { useTranslation } from 'react-i18next';
/** /**
* What a person can actually do, according to the training they completed. * What a person can actually do, according to the training they completed.
@@ -59,11 +60,12 @@ interface SkillPathProps {
} }
function SkillPath({ state }: SkillPathProps) { function SkillPath({ state }: SkillPathProps) {
const { t } = useTranslation();
const reduced = useReducedMotion(); const reduced = useReducedMotion();
const held = state.verifiedLabel; const held = state.verifiedLabel;
if (!state.nextLevel) { 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 ( return (
@@ -123,6 +125,7 @@ export function VerifiedSkills({
onOpenTraining = null, onOpenTraining = null,
className = '', className = '',
}: VerifiedSkillsProps) { }: VerifiedSkillsProps) {
const { t } = useTranslation();
/* Accepts a profile or the email of one, so a candidate record — which is an /* 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. */ application, not a worker profile — can be matched to the person behind it. */
const worker = profile || profileForEmail(profiles, email); const worker = profile || profileForEmail(profiles, email);
@@ -199,7 +202,7 @@ export function VerifiedSkills({
return ( return (
<section className={className}> <section className={className}>
<p className="text-body-sm text-ink-3"> <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> </p>
</section> </section>
); );
@@ -248,7 +251,7 @@ export function VerifiedSkills({
<div className="grid gap-5 sm:grid-cols-2"> <div className="grid gap-5 sm:grid-cols-2">
<div> <div>
<h4 className="mb-2 font-heading text-caption font-bold uppercase tracking-wide text-ink-4"> <h4 className="mb-2 font-heading text-caption font-bold uppercase tracking-wide text-ink-4">
Recently earned {t('verifiedSkills.recentlyEarned')}
</h4> </h4>
{recent.length ? ( {recent.length ? (
<ul className="space-y-1.5"> <ul className="space-y-1.5">
@@ -260,13 +263,13 @@ export function VerifiedSkills({
))} ))}
</ul> </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>
<div> <div>
<h4 className="mb-2 font-heading text-caption font-bold uppercase tracking-wide text-ink-4"> <h4 className="mb-2 font-heading text-caption font-bold uppercase tracking-wide text-ink-4">
Next recommended training {t('verifiedSkills.nextRecommendedTraining')}
</h4> </h4>
{nextUp ? ( {nextUp ? (
<div className="rounded-xl border border-krow-blue/30 bg-krow-blue-tint/40 p-3"> <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} {nextUp.module.title}
</p> </p>
<p className="mt-0.5 text-caption text-ink-2"> <p className="mt-0.5 text-caption text-ink-2">
Unlocks {nextUp.state.name} — {levelLabel(nextUp.module.target_level)} {t('verifiedSkills.unlocks')} {nextUp.state.name} — {levelLabel(nextUp.module.target_level)}
{nextUp.opens && <> · would improve the match for {nextUp.opens.title}</>} {nextUp.opens && <> {t('verifiedSkills.wouldImproveTheMatchFor')} {nextUp.opens.title}</>}
</p> </p>
{onOpenTraining && ( {onOpenTraining && (
<button <button
@@ -284,12 +287,12 @@ export function VerifiedSkills({
onClick={() => onOpenTraining(nextUp.module, worker)} onClick={() => onOpenTraining(nextUp.module, worker)}
className="mt-2 inline-flex items-center gap-1 text-caption font-semibold text-krow-blue hover:underline" 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> </button>
)} )}
</div> </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>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { getScoreBand } from '@/lib/talentHome'; import { getScoreBand } from '@/lib/talentHome';
import ReferencesEndorsements from '@/components/krow/ReferencesEndorsements'; import ReferencesEndorsements from '@/components/krow/ReferencesEndorsements';
import { useTranslation } from 'react-i18next';
const FICO_MIN = 300; const FICO_MIN = 300;
const FICO_MAX = 850; const FICO_MAX = 850;
@@ -50,6 +51,7 @@ interface CareerScorePrimaryProps {
} }
function CareerScorePrimary({ application }: CareerScorePrimaryProps) { function CareerScorePrimary({ application }: CareerScorePrimaryProps) {
const { t } = useTranslation();
const score = careerScoreFromApp(application); const score = careerScoreFromApp(application);
const band = getScoreBand(Math.round(application?.ai_score || 0)); 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}.`} 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"> <span className="text-[9px] font-semibold uppercase leading-none tracking-[0.08em] text-ink-4">
Career score {t('workforceReputation.careerScore')}
</span> </span>
<span className="mt-1.5 font-heading text-[32px] font-bold leading-none tabular-nums text-krow-blue"> <span className="mt-1.5 font-heading text-[32px] font-bold leading-none tabular-nums text-krow-blue">
{score} {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"> <dl className="grid gap-x-6 gap-y-4 min-[420px]:grid-cols-2 sm:grid-cols-1 lg:grid-cols-2">
<div> <div>
<dt className="text-[10px] font-semibold uppercase tracking-[0.06em] text-ink-4"> <dt className="text-[10px] font-semibold uppercase tracking-[0.06em] text-ink-4">
Potential market value {t('workforceReputation.potentialMarketValue')}
</dt> </dt>
<dd className="mt-1 font-heading text-body-lg font-bold text-ink-1"> <dd className="mt-1 font-heading text-body-lg font-bold text-ink-1">
{marketRangeFromApp(application)} {marketRangeFromApp(application)}
@@ -130,7 +132,7 @@ function CareerScorePrimary({ application }: CareerScorePrimaryProps) {
</div> </div>
<div> <div>
<dt className="text-[10px] font-semibold uppercase tracking-[0.06em] text-ink-4"> <dt className="text-[10px] font-semibold uppercase tracking-[0.06em] text-ink-4">
Next promotion {t('workforceReputation.nextPromotion')}
</dt> </dt>
<dd className="mt-1 font-heading text-body font-semibold text-ink-1"> <dd className="mt-1 font-heading text-body font-semibold text-ink-1">
{nextTierFromApp(application)} {nextTierFromApp(application)}
@@ -209,6 +211,7 @@ interface WorkforceReputationProps {
} }
export default function WorkforceReputation({ application }: WorkforceReputationProps) { export default function WorkforceReputation({ application }: WorkforceReputationProps) {
const { t } = useTranslation();
/* `score_breakdown` is a `jsonb` column, typed `unknown` by the registry. /* `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 This panel reads four named dimensions out of it; what else it holds is
still unstated. */ still unstated. */
@@ -233,7 +236,7 @@ export default function WorkforceReputation({ application }: WorkforceReputation
<Shield className="h-4 w-4 text-white" strokeWidth={2.5} /> <Shield className="h-4 w-4 text-white" strokeWidth={2.5} />
</div> </div>
<h4 className="flex items-center gap-1.5 text-[12px] font-bold tracking-wide text-ink-1"> <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" /> <TrendingUp className="h-3.5 w-3.5 text-krow-blue" />
</h4> </h4>
</div> </div>
@@ -243,12 +246,12 @@ export default function WorkforceReputation({ application }: WorkforceReputation
{/* Layer 3 — supporting evidence. A hairline is enough to separate it from {/* Layer 3 — supporting evidence. A hairline is enough to separate it from
the score; another container would make it a second card. */} 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"> <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={t('workforceReputation.reliability')} value={reliability} suffix="%" />
<SupportingSignal label="Attendance" value={attendance} suffix="%" /> <SupportingSignal label={t('workforceReputation.attendance')} value={attendance} suffix="%" />
<SupportingSignal label="Leadership" value={leadership} suffix="%" /> <SupportingSignal label={t('workforceReputation.leadership')} value={leadership} suffix="%" />
<SupportingSignal label="Communication" value={commText} /> <SupportingSignal label={t('workforceReputation.communication')} value={commText} />
<SupportingSignal <SupportingSignal
label="Client rating" label={t('workforceReputation.clientRating')}
value={clientRating > 0 ? clientRating.toFixed(1) : null} value={clientRating > 0 ? clientRating.toFixed(1) : null}
icon={Star} icon={Star}
/> />

View File

@@ -10,6 +10,7 @@ import MediaChallenge from './MediaChallenge';
import VideoRecorder from './VideoRecorder'; import VideoRecorder from './VideoRecorder';
import HazardIdentifier from './HazardIdentifier'; import HazardIdentifier from './HazardIdentifier';
import OwliverAvatar from '@/components/krow/OwliverAvatar'; import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useTranslation } from 'react-i18next';
/** /**
* The challenge itself: learn, demonstrate, verify, proof. * The challenge itself: learn, demonstrate, verify, proof.
@@ -84,6 +85,7 @@ interface ChallengeRunnerProps {
} }
export default function ChallengeRunner({ course, profile, onPassed }: ChallengeRunnerProps) { export default function ChallengeRunner({ course, profile, onPassed }: ChallengeRunnerProps) {
const { t } = useTranslation();
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [evaluating, setEvaluating] = useState(false); const [evaluating, setEvaluating] = useState(false);
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
@@ -134,9 +136,9 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
{!lock.unlocked ? ( {!lock.unlocked ? (
<> <>
<p className="flex items-center gap-2 text-body font-semibold text-ink-1"> <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>
<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"> <ul className="mt-2.5 space-y-1.5">
{lock.reasons.map((r, i) => ( {lock.reasons.map((r, i) => (
<li key={i} className="flex items-start gap-2 text-body-sm text-ink-2"> <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" /> <Icon className="h-5 w-5" aria-hidden="true" />
</span> </span>
<div className="min-w-0"> <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> <p className="text-body font-semibold text-ink-1">{TYPE_LABEL[type]}</p>
</div> </div>
</div> </div>
@@ -161,7 +163,7 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
{ch.prompt || course?.description} {ch.prompt || course?.description}
</p> </p>
<Button shape="rounded" className="mt-4 w-full" onClick={() => setStep(1)}> <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> </Button>
</> </>
)} )}
@@ -176,8 +178,8 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
<Stepper active={1} /> <Stepper active={1} />
<div className="flex flex-col items-center gap-2 py-6"> <div className="flex flex-col items-center gap-2 py-6">
<Spinner size="lg" /> <Spinner size="lg" />
<p className="text-body-sm font-medium text-ink-1">Owliver is evaluating your proof…</p> <p className="text-body-sm font-medium text-ink-1">{t('challengeRunner.owliverIsEvaluatingYourProof')}</p>
<p className="text-caption text-ink-3">Measuring whether you can actually perform.</p> <p className="text-caption text-ink-3">{t('challengeRunner.measuringWhetherYouCanActuallyPerform')}</p>
</div> </div>
</Frame> </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"> <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" /> <OwliverAvatar className="h-7 w-7 shrink-0" rounded="rounded-full" />
<div className="min-w-0"> <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> <p className="text-body-sm leading-relaxed text-ink-2">{ch.prompt || course?.description}</p>
</div> </div>
</div> </div>
@@ -220,7 +222,7 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
<div className="min-w-0"> <div className="min-w-0">
<p className="font-heading text-title text-ink-1">{passed ? 'Verified by Owliver' : 'Needs work'}</p> <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"> <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> </p>
</div> </div>
</div> </div>
@@ -276,11 +278,11 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
{passed ? ( {passed ? (
<Button shape="rounded" className="mt-3 w-full" onClick={() => setStep(3)}> <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>
) : ( ) : (
<Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(1)}> <Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(1)}>
Try the demonstration again {t('challengeRunner.tryTheDemonstrationAgain')}
</Button> </Button>
)} )}
</Frame> </Frame>
@@ -296,8 +298,8 @@ export default function ChallengeRunner({ course, profile, onPassed }: Challenge
<Award className="h-5 w-5" aria-hidden="true" /> <Award className="h-5 w-5" aria-hidden="true" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-body font-semibold text-ink-1">Proof stored</p> <p className="text-body font-semibold text-ink-1">{t('challengeRunner.proofStored')}</p>
<p className="text-caption text-ink-3">Capability: {course.proof_skill || course.title}</p> <p className="text-caption text-ink-3">{t('challengeRunner.capability')} {course.proof_skill || course.title}</p>
</div> </div>
</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"> <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" /> <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> </p>
<Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(0)}> <Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(0)}>
Back to start {t('challengeRunner.backToStart')}
</Button> </Button>
</Frame> </Frame>
); );

View File

@@ -2,6 +2,7 @@ import type { CourseView } from '../types';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Trash2, Plus } from 'lucide-react'; import { Trash2, Plus } from 'lucide-react';
import { Image } from '@/components/ui/image'; import { Image } from '@/components/ui/image';
import { useTranslation } from 'react-i18next';
const PRESET_TAGS = [ const PRESET_TAGS = [
'Raw meat next to ready-to-eat food', 'Raw meat next to ready-to-eat food',
@@ -21,6 +22,7 @@ interface HazardIdentifierProps {
} }
export default function HazardIdentifier({ course, onSubmit, submitting }: HazardIdentifierProps) { export default function HazardIdentifier({ course, onSubmit, submitting }: HazardIdentifierProps) {
const { t } = useTranslation();
const imageUrl = course?.challenge?.image_url; const imageUrl = course?.challenge?.image_url;
const [markers, setMarkers] = useState([]); const [markers, setMarkers] = useState([]);
const [pending, setPending] = useState(null); const [pending, setPending] = useState(null);
@@ -45,12 +47,12 @@ export default function HazardIdentifier({ course, onSubmit, submitting }: Hazar
return ( return (
<div> <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}> <div className="relative rounded-xl overflow-hidden border border-border mb-3 select-none cursor-crosshair" onClick={addMarker}>
{imageUrl ? ( {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) => ( {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()}> <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 && ( {pending && (
<div className="rounded-xl border border-krow-blue bg-krow-blue-tint p-3 mb-3"> <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"> <div className="flex flex-wrap gap-1.5 mb-2">
{PRESET_TAGS.map((t) => ( {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> <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>
<div className="flex gap-2"> <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" /> <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" /> Add</button> <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>
</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"> <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> </button>
</div> </div>
); );

View File

@@ -3,6 +3,7 @@ import React, { useState } from 'react';
import { Loader2, Camera } from 'lucide-react'; import { Loader2, Camera } from 'lucide-react';
import { base44 } from '@/api/base44Client'; import { base44 } from '@/api/base44Client';
import { Image } from '@/components/ui/image'; import { Image } from '@/components/ui/image';
import { useTranslation } from 'react-i18next';
// Photo or video proof — capture/upload, then submit for AI evaluation. // Photo or video proof — capture/upload, then submit for AI evaluation.
interface MediaChallengeProps { interface MediaChallengeProps {
@@ -12,6 +13,7 @@ interface MediaChallengeProps {
} }
export default function MediaChallenge({ course, onSubmit, submitting }: MediaChallengeProps) { export default function MediaChallenge({ course, onSubmit, submitting }: MediaChallengeProps) {
const { t } = useTranslation();
const type = course?.challenge?.type || 'photo'; const type = course?.challenge?.type || 'photo';
const [mediaUrl, setMediaUrl] = useState(''); const [mediaUrl, setMediaUrl] = useState('');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -43,7 +45,7 @@ export default function MediaChallenge({ course, onSubmit, submitting }: MediaCh
{type === 'video' ? ( {type === 'video' ? (
<video src={mediaUrl} controls className="w-full max-h-64 object-cover" /> <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> </div>
) : ( ) : (
@@ -53,7 +55,7 @@ export default function MediaChallenge({ course, onSubmit, submitting }: MediaCh
) : ( ) : (
<> <>
<Camera className="w-6 h-6 text-ink-4 mb-2" /> <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} /> <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"> <div className="flex gap-2">
{mediaUrl && ( {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 <button
onClick={() => onSubmit(mediaUrl)} onClick={() => onSubmit(mediaUrl)}
disabled={!mediaUrl || uploading || submitting} disabled={!mediaUrl || uploading || submitting}
className="flex-1 h-10 rounded-xl bg-krow-navy text-white text-[13px] font-semibold disabled:opacity-40" 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> </button>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { Send, Loader2 } from 'lucide-react'; import { Send, Loader2 } from 'lucide-react';
import { challengeFollowUp } from '@/lib/provingGround'; import { challengeFollowUp } from '@/lib/provingGround';
import OwliverAvatar from '@/components/krow/OwliverAvatar'; import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useTranslation } from 'react-i18next';
// AI roleplay challenge — scenario prompt, worker responds, KROW follows up, // AI roleplay challenge — scenario prompt, worker responds, KROW follows up,
// then the worker submits the transcript for AI evaluation. // then the worker submits the transcript for AI evaluation.
@@ -13,6 +14,7 @@ interface RoleplayChallengeProps {
} }
export default function RoleplayChallenge({ course, onSubmit, submitting }: RoleplayChallengeProps) { export default function RoleplayChallenge({ course, onSubmit, submitting }: RoleplayChallengeProps) {
const { t } = useTranslation();
const initial = course?.challenge?.prompt || course?.description || 'Demonstrate the skill.'; const initial = course?.challenge?.prompt || course?.description || 'Demonstrate the skill.';
const [messages, setMessages] = useState([{ role: 'ai', content: initial }]); const [messages, setMessages] = useState([{ role: 'ai', content: initial }]);
const [input, setInput] = useState(''); 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"> <div className="flex items-center gap-2 mb-3">
<OwliverAvatar className="w-8 h-8" rounded="rounded-full" /> <OwliverAvatar className="w-8 h-8" rounded="rounded-full" />
<div> <div>
<p className="text-[13px] font-semibold text-ink-1">Owliver</p> <p className="text-[13px] font-semibold text-ink-1">{t('roleplayChallenge.owliver')}</p>
<p className="text-[10px] text-ink-4">Your AI skills coach</p> <p className="text-[10px] text-ink-4">{t('roleplayChallenge.yourAiSkillsCoach')}</p>
</div> </div>
</div> </div>
<div className="space-y-3 mb-4 max-h-72 overflow-y-auto pr-1"> <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)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()} onKeyDown={(e) => e.key === 'Enter' && send()}
disabled={submitting || busy} 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" 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"> <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} 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" 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> </button>
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { Loader2, Video, Square, RotateCcw } from 'lucide-react'; import { Loader2, Video, Square, RotateCcw } from 'lucide-react';
import { base44 } from '@/api/base44Client'; import { base44 } from '@/api/base44Client';
import { useTranslation } from 'react-i18next';
// Real in-browser video recording — "record yourself washing your hands." // Real in-browser video recording — "record yourself washing your hands."
interface VideoRecorderProps { interface VideoRecorderProps {
@@ -9,6 +10,7 @@ interface VideoRecorderProps {
} }
export default function VideoRecorder({ onSubmit, submitting }: VideoRecorderProps) { export default function VideoRecorder({ onSubmit, submitting }: VideoRecorderProps) {
const { t } = useTranslation();
const videoRef = useRef(null); const videoRef = useRef(null);
const mediaRecorderRef = useRef(null); const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]); const chunksRef = useRef([]);
@@ -93,13 +95,13 @@ export default function VideoRecorder({ onSubmit, submitting }: VideoRecorderPro
</div> </div>
{!recordedUrl ? ( {!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'}`}> <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> </button>
) : ( ) : (
<div className="flex gap-2"> <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"> <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> </button>
</div> </div>
)} )}

View File

@@ -1,5 +1,6 @@
import type { ProfileView } from '../types'; import type { ProfileView } from '../types';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
const LADDER = ['Beginner', 'Cross-Trained', 'Skilled', 'Lead', 'Manager']; const LADDER = ['Beginner', 'Cross-Trained', 'Skilled', 'Lead', 'Manager'];
@@ -8,12 +9,13 @@ interface CareerGrowthProps {
} }
export default function CareerGrowth({ profile }: CareerGrowthProps) { export default function CareerGrowth({ profile }: CareerGrowthProps) {
const { t } = useTranslation();
const lp = Number(profile.leadership_potential) || 0; const lp = Number(profile.leadership_potential) || 0;
const currentIdx = lp >= 80 ? 3 : lp >= 60 ? 2 : lp >= 40 ? 1 : 0; const currentIdx = lp >= 80 ? 3 : lp >= 60 ? 2 : lp >= 40 ? 1 : 0;
return ( return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8"> <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> <h2 className="text-[18px] font-bold text-[#0F172A]">{t('careerGrowth.careerGrowth')}</h2>
<p className="text-[13px] text-[#6B7280] mb-5">People don't just work. They level up.</p> <p className="text-[13px] text-[#6B7280] mb-5">{t('careerGrowth.peopleDontJustWorkTheyLevel')}</p>
<div className="flex flex-col"> <div className="flex flex-col">
{LADDER.map((step, i) => { {LADDER.map((step, i) => {
const done = i < currentIdx; const done = i < currentIdx;
@@ -29,8 +31,8 @@ export default function CareerGrowth({ profile }: CareerGrowthProps) {
</div> </div>
<div className={`pb-4 pt-1.5 text-[14px] font-medium ${current ? 'text-[#0838E0]' : done ? 'text-[#0F172A]' : 'text-[#9CA3AF]'}`}> <div className={`pb-4 pt-1.5 text-[14px] font-medium ${current ? 'text-[#0838E0]' : done ? 'text-[#0F172A]' : 'text-[#9CA3AF]'}`}>
{step} {step}
{current && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">You are here</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]">Target</span>} {last && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">{t('careerGrowth.target')}</span>}
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
import type { ProfileView } from '../types'; import type { ProfileView } from '../types';
import React from 'react'; import React from 'react';
import { ShieldCheck } from 'lucide-react'; import { ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const ITEMS = [ const ITEMS = [
'Identity', 'Identity',
@@ -18,6 +19,7 @@ interface CareerPassportProps {
} }
export default function CareerPassport({ profile }: CareerPassportProps) { export default function CareerPassport({ profile }: CareerPassportProps) {
const { t } = useTranslation();
const certs = (profile.certifications || []).map((c) => c.toLowerCase()); const certs = (profile.certifications || []).map((c) => c.toLowerCase());
const skills = (profile.skills || []).map((s) => s.toLowerCase()); const skills = (profile.skills || []).map((s) => s.toLowerCase());
const badges = (profile.earned_badges || []).map((b) => (b.name || '').toLowerCase()); const badges = (profile.earned_badges || []).map((b) => (b.name || '').toLowerCase());
@@ -51,8 +53,8 @@ export default function CareerPassport({ profile }: CareerPassportProps) {
return ( return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6"> <section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Career Passport</h2> <h2 className="text-[15px] font-semibold text-[#0F172A]">{t('careerPassport.careerPassport')}</h2>
<span className="text-[12px] font-semibold text-[#0838E0]">{pct}% Complete</span> <span className="text-[12px] font-semibold text-[#0838E0]">{pct}{t('careerPassport.complete')}</span>
</div> </div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{ITEMS.map((label) => { {ITEMS.map((label) => {

View File

@@ -3,6 +3,7 @@ import React from 'react';
import { Info, RefreshCw } from 'lucide-react'; import { Info, RefreshCw } from 'lucide-react';
import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge'; import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge';
import { getTalentScore, toFICO, getScoreBand } from '@/lib/talentHome'; import { getTalentScore, toFICO, getScoreBand } from '@/lib/talentHome';
import { useTranslation } from 'react-i18next';
const FICO_MIN = 300; const FICO_MIN = 300;
const FICO_MAX = 850; const FICO_MAX = 850;
@@ -13,6 +14,7 @@ interface CareerScoreGaugeProps {
} }
export default function CareerScoreGauge({ profile }: CareerScoreGaugeProps) { export default function CareerScoreGauge({ profile }: CareerScoreGaugeProps) {
const { t } = useTranslation();
const raw = getTalentScore(profile); const raw = getTalentScore(profile);
const score = toFICO(raw); const score = toFICO(raw);
const band = getScoreBand(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" 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}`} 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-[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> <span className="text-[9px] text-[#8690A1] font-medium mt-0.5">{FICO_MIN}–{FICO_MAX}</span>
</div> </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]"> <div className="inline-flex items-center gap-1.5 mt-2 text-[11px] text-[#7D8590]">
<RefreshCw className="w-3 h-3" /> <RefreshCw className="w-3 h-3" />
Next update in 31 days {t('careerScoreGauge.nextUpdateIn31Days')}
</div> </div>
</div> </div>
); );

View File

@@ -2,12 +2,14 @@ import type { ProfileView } from '../types';
import React from 'react'; import React from 'react';
import { Briefcase, Sparkles, Star, Quote } from 'lucide-react'; import { Briefcase, Sparkles, Star, Quote } from 'lucide-react';
import { esatTag, recentJobs, clientEndorsements } from '@/lib/talentInsights'; import { esatTag, recentJobs, clientEndorsements } from '@/lib/talentInsights';
import { useTranslation } from 'react-i18next';
interface EmployerInsightsProps { interface EmployerInsightsProps {
profile: ProfileView; profile: ProfileView;
} }
export default function EmployerInsights({ profile }: EmployerInsightsProps) { export default function EmployerInsights({ profile }: EmployerInsightsProps) {
const { t } = useTranslation();
const esat = esatTag(profile); const esat = esatTag(profile);
const jobs = recentJobs(profile); const jobs = recentJobs(profile);
const endorsements = clientEndorsements(profile); const endorsements = clientEndorsements(profile);
@@ -19,7 +21,7 @@ export default function EmployerInsights({ profile }: EmployerInsightsProps) {
{/* ESAT + Job history header row */} {/* ESAT + Job history header row */}
<div className="flex items-center justify-between gap-2"> <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"> <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> </p>
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#9CA3AF] tracking-wider"> <span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#9CA3AF] tracking-wider">
<Sparkles className="w-3 h-3" /> ESAT <Sparkles className="w-3 h-3" /> ESAT
@@ -44,7 +46,7 @@ export default function EmployerInsights({ profile }: EmployerInsightsProps) {
{endorsements.length > 0 && ( {endorsements.length > 0 && (
<div> <div>
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5"> <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> </p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{endorsements.map((en, i) => ( {endorsements.map((en, i) => (

View File

@@ -4,6 +4,7 @@ import { Star, Briefcase, Quote } from 'lucide-react';
import { verifiedExperience } from './VerifiedExperienceList'; import { verifiedExperience } from './VerifiedExperienceList';
export { verifiedExperience }; export { verifiedExperience };
import { clientEndorsements } from '@/lib/talentInsights'; import { clientEndorsements } from '@/lib/talentInsights';
import { useTranslation } from 'react-i18next';
/** Props for the local `Stars` helper in this file. Optional throughout: /** Props for the local `Stars` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */ the JavaScript validated none of them and callers omit freely. */
@@ -34,6 +35,7 @@ interface ExperienceEndorsementsProps {
} }
export default function ExperienceEndorsements({ profile, onReference }: ExperienceEndorsementsProps) { export default function ExperienceEndorsements({ profile, onReference }: ExperienceEndorsementsProps) {
const { t } = useTranslation();
const jobs = verifiedExperience(profile); const jobs = verifiedExperience(profile);
if (jobs.length === 0) return null; if (jobs.length === 0) return null;
@@ -62,12 +64,12 @@ export default function ExperienceEndorsements({ profile, onReference }: Experie
onClick={() => onReference?.(x)} 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" 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> </button>
</div> </div>
</div> </div>
<p className="text-[11px] text-[#16A34A] font-medium mt-1 truncate"> <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>} {x.years != null && <span className="text-[#9CA3AF]"> · {x.years} yr</span>}
</p> </p>
{en && ( {en && (

View File

@@ -1,6 +1,7 @@
import type { ProfileView } from '../types'; import type { ProfileView } from '../types';
import React from 'react'; import React from 'react';
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const SEED_NETWORK = ['Luis', 'Chef Antonio']; const SEED_NETWORK = ['Luis', 'Chef Antonio'];
const SEED_ROLES = ['Supervisor', 'Chef']; const SEED_ROLES = ['Supervisor', 'Chef'];
@@ -10,6 +11,7 @@ interface ExperienceNetworkProps {
} }
export default function ExperienceNetwork({ profile }: ExperienceNetworkProps) { export default function ExperienceNetwork({ profile }: ExperienceNetworkProps) {
const { t } = useTranslation();
const exp = (profile.experience || []).slice(0, 3); const exp = (profile.experience || []).slice(0, 3);
const list = exp.length const list = exp.length
? exp.map((e, i) => ({ ? exp.map((e, i) => ({
@@ -27,13 +29,13 @@ export default function ExperienceNetwork({ profile }: ExperienceNetworkProps) {
return ( return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6"> <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"> <div className="mt-4 space-y-2">
{list.map((x, i) => ( {list.map((x, i) => (
<div key={i} className="flex items-center justify-between rounded-xl border border-[#E5E7EB] p-3"> <div key={i} className="flex items-center justify-between rounded-xl border border-[#E5E7EB] p-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[14px] font-semibold text-[#0F172A] truncate">{x.company}</p> <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>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<div className="flex"> <div className="flex">

View File

@@ -6,6 +6,7 @@ import { toast } from 'react-hot-toast';
import { Gauge as MuiGauge, gaugeClasses } from '@mui/x-charts/Gauge'; import { Gauge as MuiGauge, gaugeClasses } from '@mui/x-charts/Gauge';
import { Image } from '@/components/ui/image'; import { Image } from '@/components/ui/image';
import { getTalentScore, getScoreBand, getGrowth } from '@/lib/talentHome'; 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))); 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) { function Gauge({ fico }: GaugeProps) {
const { t } = useTranslation();
return ( return (
<div className="relative mx-auto flex items-center justify-center" style={{ width: 140, height: 80 }}> <div className="relative mx-auto flex items-center justify-center" style={{ width: 140, height: 80 }}>
<MuiGauge <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" 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`} 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-[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> <span className="text-[9px] text-[#8690A1] font-medium mt-0.5">300–850</span>
</div> </div>
@@ -139,6 +141,7 @@ interface IdentityCareerCardProps {
} }
export default function IdentityCareerCard({ profile }: IdentityCareerCardProps) { export default function IdentityCareerCard({ profile }: IdentityCareerCardProps) {
const { t } = useTranslation();
const stackRef = useRef(null); const stackRef = useRef(null);
const [busy, setBusy] = useState(false); 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> <span className="text-[20px] font-bold text-[#0838E0]">{initials}</span>
)} )}
</div> </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> <h2 className="text-[20px] font-bold text-[#0F172A] tracking-tight mt-1">{name}</h2>
<p className="text-[12px] text-[#6B7280]">{title}</p> <p className="text-[12px] text-[#6B7280]">{title}</p>
<div className="mt-1.5 inline-flex items-center gap-1 text-[10px] text-[#9CA3AF]"> <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> </div>
{/* Career Score gauge */} {/* Career Score gauge */}
@@ -221,27 +224,27 @@ export default function IdentityCareerCard({ profile }: IdentityCareerCardProps)
</span> </span>
<div className="inline-flex items-center gap-1.5 mt-2 text-[11px] text-[#7D8590]"> <div className="inline-flex items-center gap-1.5 mt-2 text-[11px] text-[#7D8590]">
<RefreshCw className="w-3 h-3" /> <RefreshCw className="w-3 h-3" />
Next update in 31 days {t('identityCareerCard.nextUpdateIn31Days')}
</div> </div>
</div> </div>
{/* Score factors */} {/* Score factors */}
<div className="mt-4 text-left"> <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"> <div className="flex flex-wrap gap-1.5 justify-center">
<FactorPill label="Reliability" value={clamp(profile.reliability_score)} /> <FactorPill label={t('identityCareerCard.reliability')} value={clamp(profile.reliability_score)} />
<FactorPill label="Attendance" value={clamp(profile.attendance_score ?? 100)} /> <FactorPill label={t('identityCareerCard.attendance')} value={clamp(profile.attendance_score ?? 100)} />
<FactorPill label="Performance" value={clamp(profile.performance_score)} /> <FactorPill label={t('identityCareerCard.performance')} value={clamp(profile.performance_score)} />
<FactorPill label="Leadership" value={clamp(profile.leadership_potential)} /> <FactorPill label={t('identityCareerCard.leadership')} value={clamp(profile.leadership_potential)} />
<FactorPill label="AI Interview" value={clamp(profile.ai_interview_score)} /> <FactorPill label={t('identityCareerCard.aiInterview')} value={clamp(profile.ai_interview_score)} />
<FactorPill label="XP Earned" value={Number(profile.xp) || 0} /> <FactorPill label={t('identityCareerCard.xpEarned')} value={Number(profile.xp) || 0} />
</div> </div>
</div> </div>
{/* Report rows */} {/* Report rows */}
<div className="mt-4 text-left rounded-2xl bg-white/60 border border-white/70 p-3 backdrop-blur-sm"> <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={ShieldCheck} label={t('identityCareerCard.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={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'} /> <ReportRow icon={Sparkles} label="ESAT" value={`${esat}/100`} tone={esat >= 75 ? 'verified' : esat >= 40 ? 'amber' : 'neutral'} />
</div> </div>
@@ -262,23 +265,23 @@ export default function IdentityCareerCard({ profile }: IdentityCareerCardProps)
{/* Take your KROW ID */} {/* Take your KROW ID */}
<div className="mt-4 text-left"> <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="flex items-center gap-3">
<div className="rounded-xl bg-white p-1.5 border border-[#E6ECF5] shadow-sm shrink-0"> <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>
<div className="flex-1 flex flex-col gap-1.5"> <div className="flex-1 flex flex-col gap-1.5">
<div className="grid grid-cols-2 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-[#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]" /> Google</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>
<div className="grid grid-cols-2 gap-1.5"> <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> <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> </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> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@ import type { CourseView } from '../types';
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Clock, ArrowRight } from 'lucide-react'; import { Clock, ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const UNLOCKS = ['Google, Adobe, Apple', 'Lead Cook', 'Senior roles']; const UNLOCKS = ['Google, Adobe, Apple', 'Lead Cook', 'Senior roles'];
@@ -16,11 +17,12 @@ interface IncreaseMarketValueProps {
} }
export default function IncreaseMarketValue({ courses = [] }: IncreaseMarketValueProps) { export default function IncreaseMarketValue({ courses = [] }: IncreaseMarketValueProps) {
const { t } = useTranslation();
const list = (courses || []).filter((c) => c.status === 'active').slice(0, 3); const list = (courses || []).filter((c) => c.status === 'active').slice(0, 3);
return ( return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6"> <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"> <div className="mt-4 space-y-3">
{list.map((c, i) => { {list.map((c, i) => {
const { score, pay } = roi(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]" /> <ArrowRight className="w-4 h-4 text-[#94A3B8]" />
</div> </div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[12px]"> <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="text-[#0838E0] font-semibold">+${pay.toFixed(2)}/hr</span>
<span className="inline-flex items-center gap-1 text-[#6B7280]"> <span className="inline-flex items-center gap-1 text-[#6B7280]">
<Clock className="w-3 h-3" /> {c.estimated_minutes || 15} min <Clock className="w-3 h-3" /> {c.estimated_minutes || 15} min
</span> </span>
</div> </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> </Link>
); );
})} })}

View File

@@ -1,6 +1,7 @@
import type { ProfileView } from '../types'; import type { ProfileView } from '../types';
import React from 'react'; import React from 'react';
import { Eye, FileText, Calendar, Clock } from 'lucide-react'; import { Eye, FileText, Calendar, Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const SHIFTS = ['Morning Shift', 'Evening Shift', 'Weekend', 'Full Time']; const SHIFTS = ['Morning Shift', 'Evening Shift', 'Weekend', 'Full Time'];
const STATES = ['Interested', 'Interview', 'Offer', 'Offer']; const STATES = ['Interested', 'Interview', 'Offer', 'Offer'];
@@ -11,6 +12,7 @@ interface MarketOffersProps {
} }
export default function MarketOffers({ profile, jobRecs = [] }: MarketOffersProps) { export default function MarketOffers({ profile, jobRecs = [] }: MarketOffersProps) {
const { t } = useTranslation();
const companies = (jobRecs || []).slice(0, 3).map(r => (r.title || 'Company').split(' ')[0]); const companies = (jobRecs || []).slice(0, 3).map(r => (r.title || 'Company').split(' ')[0]);
const availability = (profile.availability || [])[0] || 'Available This Week'; const availability = (profile.availability || [])[0] || 'Available This Week';
const offers = (jobRecs || []).slice(0, 4); const offers = (jobRecs || []).slice(0, 4);
@@ -24,7 +26,7 @@ export default function MarketOffers({ profile, jobRecs = [] }: MarketOffersProp
return ( return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6"> <section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Your Market & Competing Offers</h2> <h2 className="text-[15px] font-semibold text-[#0F172A]">{t('marketOffers.yourMarketCompetingOffers')}</h2>
{/* Market stats */} {/* Market stats */}
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -53,9 +55,9 @@ export default function MarketOffers({ profile, jobRecs = [] }: MarketOffersProp
{/* Competing offers */} {/* Competing offers */}
<div className="mt-5 pt-4 border-t border-[#E5E7EB]"> <div className="mt-5 pt-4 border-t border-[#E5E7EB]">
<p className="text-[13px] font-semibold text-[#0F172A] mb-3">Companies Competing For You</p> <p className="text-[13px] font-semibold text-[#0F172A] mb-3">{t('marketOffers.companiesCompetingForYou')}</p>
{offers.length === 0 ? ( {offers.length === 0 ? (
<p className="text-[13px] text-[#6B7280] py-4">No live offers yet — keep leveling up.</p> <p className="text-[13px] text-[#6B7280] py-4">{t('marketOffers.noLiveOffersYetKeepLeveling')}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{offers.map((j, i) => { {offers.map((j, i) => {
@@ -76,17 +78,17 @@ export default function MarketOffers({ profile, jobRecs = [] }: MarketOffersProp
<span className="text-[#6B7280]">{shift}</span> <span className="text-[#6B7280]">{shift}</span>
</div> </div>
</div> </div>
{i === 1 && <span className="text-[11px] text-[#94A3B8]">Tomorrow</span>} {i === 1 && <span className="text-[11px] text-[#94A3B8]">{t('marketOffers.tomorrow')}</span>}
</div> </div>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0838E0] rounded-full py-1.5 hover:bg-[#062BAF] transition-colors"> <button className="flex-1 text-[12px] font-semibold text-white bg-[#0838E0] rounded-full py-1.5 hover:bg-[#062BAF] transition-colors">
Accept {t('marketOffers.accept')}
</button> </button>
<button className="flex-1 text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full py-1.5 hover:bg-[#EEF3FE] transition-colors"> <button className="flex-1 text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full py-1.5 hover:bg-[#EEF3FE] transition-colors">
Counter {t('marketOffers.counter')}
</button> </button>
<button className="text-[12px] font-semibold text-[#94A3B8] border border-[#E5E7EB] rounded-full px-3 py-1.5 hover:text-[#EF4444] hover:border-[#FECACA] transition-colors"> <button className="text-[12px] font-semibold text-[#94A3B8] border border-[#E5E7EB] rounded-full px-3 py-1.5 hover:text-[#EF4444] hover:border-[#FECACA] transition-colors">
Decline {t('marketOffers.decline')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Briefcase, MapPin, Wallet, Sparkles, Compass, ArrowRight } from 'lucide-react'; import { Briefcase, MapPin, Wallet, Sparkles, Compass, ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/** /**
* Opportunities — the Employee opportunity-discovery grid. * Opportunities — the Employee opportunity-discovery grid.
@@ -24,6 +25,7 @@ interface OpportunityCardProps {
} }
function OpportunityCard({ rec }: OpportunityCardProps) { function OpportunityCard({ rec }: OpportunityCardProps) {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { job, match } = rec; const { job, match } = rec;
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null; const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
@@ -43,7 +45,7 @@ function OpportunityCard({ rec }: OpportunityCardProps) {
</div> </div>
{isHiring && ( {isHiring && (
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full"> <span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">
Hiring {t('opportunities.hiring')}
</span> </span>
)} )}
</div> </div>
@@ -67,10 +69,10 @@ function OpportunityCard({ rec }: OpportunityCardProps) {
<div className="mt-4 flex items-center justify-between pt-4 border-t border-[#F3F4F6]"> <div className="mt-4 flex items-center justify-between pt-4 border-t border-[#F3F4F6]">
<span className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0838E0] bg-[#EEF3FE] px-2.5 py-1 rounded-full"> <span className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0838E0] bg-[#EEF3FE] px-2.5 py-1 rounded-full">
<Sparkles className="w-3 h-3" /> {match}% match <Sparkles className="w-3 h-3" /> {match}{t('opportunities.match')}
</span> </span>
<span className="inline-flex items-center gap-1 text-[13px] font-semibold text-[#0838E0] group-hover:gap-1.5 transition-all"> <span className="inline-flex items-center gap-1 text-[13px] font-semibold text-[#0838E0] group-hover:gap-1.5 transition-all">
View Position <ArrowRight className="w-4 h-4" /> {t('opportunities.viewPosition')} <ArrowRight className="w-4 h-4" />
</span> </span>
</div> </div>
</button> </button>
@@ -82,14 +84,15 @@ interface OpportunitiesProps {
} }
export default function Opportunities({ jobRecs = [] }: OpportunitiesProps) { export default function Opportunities({ jobRecs = [] }: OpportunitiesProps) {
const { t } = useTranslation();
return ( return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8"> <section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div> <div>
<h2 className="text-[18px] font-bold text-[#0F172A] flex items-center gap-2"> <h2 className="text-[18px] font-bold text-[#0F172A] flex items-center gap-2">
<Compass className="w-4 h-4 text-[#0838E0]" /> Opportunities for you <Compass className="w-4 h-4 text-[#0838E0]" /> {t('opportunities.opportunitiesForYou')}
</h2> </h2>
<p className="text-[13px] text-[#6B7280] mt-0.5">Matched to your profile. Open one to see why you fit.</p> <p className="text-[13px] text-[#6B7280] mt-0.5">{t('opportunities.matchedToYourProfileOpenOne')}</p>
</div> </div>
{jobRecs.length > 0 && ( {jobRecs.length > 0 && (
<span className="inline-flex items-center text-[12px] font-semibold text-[#0838E0] bg-[#EEF3FE] px-3 py-1.5 rounded-full"> <span className="inline-flex items-center text-[12px] font-semibold text-[#0838E0] bg-[#EEF3FE] px-3 py-1.5 rounded-full">
@@ -103,7 +106,7 @@ export default function Opportunities({ jobRecs = [] }: OpportunitiesProps) {
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3"> <div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
<Compass className="w-6 h-6" /> <Compass className="w-6 h-6" />
</div> </div>
<p className="text-[14px] font-medium text-[#0F172A]">No matched opportunities yet — keep leveling up.</p> <p className="text-[14px] font-medium text-[#0F172A]">{t('opportunities.noMatchedOpportunitiesYetKeepLeveling')}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">

Some files were not shown because too many files have changed in this diff Show More