shakers-cli-beta 0.8.0-beta.5 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -9
- package/bin/ai-usage.js +37 -19
- package/bin/login.js +1 -1
- package/bin/mcp.js +25 -21
- package/bin/mcpb.js +5 -0
- package/package.json +3 -2
- package/src/auth-session-store.js +1 -0
- package/src/authorship.js +7 -6
- package/src/backend-request.js +2 -2
- package/src/certify-dimension-client.js +0 -54
- package/src/certify-dimension-flow.js +20 -81
- package/src/config.js +29 -8
- package/src/cv-file.js +62 -0
- package/src/device-login.js +5 -3
- package/src/find-projects-client.js +16 -6
- package/src/git-activity.js +8 -7
- package/src/git-log-prefetch.js +53 -0
- package/src/i18n.js +33 -58
- package/src/mcp-ai-usage-tool.js +150 -65
- package/src/mcp-auth-form.js +213 -44
- package/src/mcp-certify-dimension-tools.js +2 -2
- package/src/mcp-choice.js +47 -0
- package/src/mcp-install.js +32 -3
- package/src/mcp-instructions.js +27 -0
- package/src/mcp-onboarding-tools.js +3 -3
- package/src/mcp-prompts.js +73 -0
- package/src/mcp-rate-tools.js +2 -2
- package/src/mcp-register-tools.js +89 -313
- package/src/mcp-role-tools.js +177 -16
- package/src/mcp-server.js +108 -4
- package/src/mcp-signup-client.js +118 -0
- package/src/mcp-signup-tools.js +818 -0
- package/src/onboarding-client.js +28 -52
- package/src/onboarding-flow.js +3 -147
- package/src/onboarding-status-client.js +16 -1
- package/src/register-context.js +1 -17
- package/src/roles-client.js +3 -1
- package/src/scanner.js +13 -1
- package/src/session-normalizers.js +174 -0
- package/src/session-scan.js +149 -10
- package/src/signup-client.js +33 -21
- package/src/signup-copy.js +338 -0
- package/src/signup-language.js +21 -0
- package/src/usage-run-steps.js +3 -2
- package/src/work-streams.js +7 -2
package/src/session-scan.js
CHANGED
|
@@ -4,6 +4,13 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { getHomeDir } = require('./env-paths');
|
|
6
6
|
const { scrubSecrets } = require('./agent-synthesis');
|
|
7
|
+
const {
|
|
8
|
+
normalizeCodexRecord,
|
|
9
|
+
normalizeGeminiRecord,
|
|
10
|
+
normalizeContinueRecord,
|
|
11
|
+
normalizeClineRecord,
|
|
12
|
+
parseAiderHistory,
|
|
13
|
+
} = require('./session-normalizers');
|
|
7
14
|
|
|
8
15
|
// GLOBAL, machine-wide inventory of AI coding-tool SESSIONS (hybrid evaluation, Slice 1).
|
|
9
16
|
|
|
@@ -73,16 +80,39 @@ const FILE_TOOL_NAMES = new Set([
|
|
|
73
80
|
]);
|
|
74
81
|
const PLANNING_TOOL_NAMES = new Set(['ExitPlanMode', 'TodoWrite']);
|
|
75
82
|
|
|
83
|
+
// Only logs.json + session-*.jsonl under ~/.gemini/tmp are transcripts (skip checkpoints/config).
|
|
84
|
+
function isGeminiTranscriptFile(name) {
|
|
85
|
+
return name === 'logs.json' || (name.startsWith('session-') && name.endsWith('.jsonl'));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Cline / Roo Code conversation file (both forks use this filename under tasks/<id>/).
|
|
89
|
+
function isClineHistoryFile(name) {
|
|
90
|
+
return name === 'api_conversation_history.json';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// VS Code globalStorage root per OS (Cline/Roo live under <ext-id>/tasks/<id>/ here).
|
|
94
|
+
function vscodeGlobalStorageDir(home) {
|
|
95
|
+
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage');
|
|
96
|
+
if (process.platform === 'win32') return path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage');
|
|
97
|
+
return path.join(home, '.config', 'Code', 'User', 'globalStorage');
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
function toolSources(env = process.env) {
|
|
77
101
|
const home = getHomeDir(env);
|
|
78
102
|
return [
|
|
79
103
|
{ tool: 'claude', dir: path.join(home, '.claude', 'projects') },
|
|
80
|
-
{ tool: 'codex', dir: path.join(home, '.codex', 'sessions') },
|
|
104
|
+
{ tool: 'codex', dir: path.join(home, '.codex', 'sessions'), normalize: normalizeCodexRecord },
|
|
105
|
+
{ tool: 'gemini', dir: path.join(home, '.gemini', 'tmp'), fileFilter: isGeminiTranscriptFile, normalize: normalizeGeminiRecord },
|
|
106
|
+
// Kept by decision: ~/.cursor/chats is OBSOLETE (real Cursor chat is SQLite in App Support); awaiting a future SQLite extractor, no tool removed.
|
|
81
107
|
{ tool: 'cursor', dir: path.join(home, '.cursor', 'chats') },
|
|
108
|
+
{ tool: 'continue', dir: path.join(home, '.continue', 'sessions'), normalize: normalizeContinueRecord },
|
|
109
|
+
{ tool: 'cline', dir: vscodeGlobalStorageDir(home), fileFilter: isClineHistoryFile, normalize: normalizeClineRecord },
|
|
110
|
+
// Repo-level: .aider.chat.history.md lives INSIDE each git repo, so cwd = the repo toplevel.
|
|
111
|
+
{ tool: 'aider', repoLevel: true, file: '.aider.chat.history.md', parseText: parseAiderHistory },
|
|
82
112
|
];
|
|
83
113
|
}
|
|
84
114
|
|
|
85
|
-
function listSessionFiles(root) {
|
|
115
|
+
function listSessionFiles(root, fileFilter = isTranscriptFile) {
|
|
86
116
|
const out = [];
|
|
87
117
|
const stack = [root];
|
|
88
118
|
while (stack.length > 0) {
|
|
@@ -97,7 +127,7 @@ function listSessionFiles(root) {
|
|
|
97
127
|
const full = path.join(dir, entry.name);
|
|
98
128
|
if (entry.isDirectory()) {
|
|
99
129
|
stack.push(full);
|
|
100
|
-
} else if (entry.isFile() &&
|
|
130
|
+
} else if (entry.isFile() && fileFilter(entry.name)) {
|
|
101
131
|
out.push(full);
|
|
102
132
|
if (out.length >= MAX_FILES_PER_TOOL) return out;
|
|
103
133
|
}
|
|
@@ -106,6 +136,77 @@ function listSessionFiles(root) {
|
|
|
106
136
|
return out;
|
|
107
137
|
}
|
|
108
138
|
|
|
139
|
+
// Whole-file JSON reader (bounded) for `.json` transcripts (e.g. Gemini logs.json = array).
|
|
140
|
+
function readJsonArrayFile(file) {
|
|
141
|
+
let fd;
|
|
142
|
+
try {
|
|
143
|
+
fd = fs.openSync(file, 'r');
|
|
144
|
+
} catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const { size } = fs.fstatSync(fd);
|
|
149
|
+
const readLen = Math.min(size, MAX_BYTES_PER_FILE);
|
|
150
|
+
const buf = Buffer.allocUnsafe(readLen);
|
|
151
|
+
fs.readSync(fd, buf, 0, readLen, 0);
|
|
152
|
+
const parsed = JSON.parse(buf.toString('utf8'));
|
|
153
|
+
if (Array.isArray(parsed)) return parsed;
|
|
154
|
+
return parsed && typeof parsed === 'object' ? [parsed] : [];
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
} finally {
|
|
158
|
+
try { fs.closeSync(fd); } catch { /* already closed */ }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Parse a file to raw records by extension (.jsonl = per line, .json = whole array).
|
|
163
|
+
function parseRawRecords(file) {
|
|
164
|
+
return file.endsWith('.jsonl') ? readSessionObjects(file) : readJsonArrayFile(file);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Read a source's file and normalize its records into the Claude-shaped internal model.
|
|
168
|
+
// A normalizer may return an object, an array (expanded), or null/[] (fail-closed skip).
|
|
169
|
+
function readSourceObjects(source, file) {
|
|
170
|
+
const raw = parseRawRecords(file);
|
|
171
|
+
if (typeof source.normalize !== 'function') return raw;
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const r of raw) {
|
|
174
|
+
const o = source.normalize(r);
|
|
175
|
+
if (Array.isArray(o)) out.push(...o);
|
|
176
|
+
else if (o) out.push(o);
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Bounded whole-file text read (for repo-level markdown sources, e.g. Aider).
|
|
182
|
+
function readTextFile(file) {
|
|
183
|
+
let fd;
|
|
184
|
+
try {
|
|
185
|
+
fd = fs.openSync(file, 'r');
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const { size } = fs.fstatSync(fd);
|
|
191
|
+
const readLen = Math.min(size, MAX_BYTES_PER_FILE);
|
|
192
|
+
const buf = Buffer.allocUnsafe(readLen);
|
|
193
|
+
fs.readSync(fd, buf, 0, readLen, 0);
|
|
194
|
+
return buf.toString('utf8');
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
} finally {
|
|
198
|
+
try { fs.closeSync(fd); } catch { /* already closed */ }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Repo-level source: read <toplevel>/<source.file> and parse it with cwd = the toplevel.
|
|
203
|
+
function readRepoLevelObjects(source, toplevel) {
|
|
204
|
+
const text = readTextFile(path.join(toplevel, source.file));
|
|
205
|
+
if (text === null) return [];
|
|
206
|
+
const objects = source.parseText(text, toplevel);
|
|
207
|
+
return Array.isArray(objects) ? objects : [];
|
|
208
|
+
}
|
|
209
|
+
|
|
109
210
|
function pushCommand(acc, command) {
|
|
110
211
|
if (typeof command !== 'string') return;
|
|
111
212
|
acc.bashCommandsCount += 1;
|
|
@@ -198,16 +299,31 @@ function fileInScope(cwd, selectedCwds) {
|
|
|
198
299
|
|
|
199
300
|
// Yields every parsed session object across every known AI-tool session directory on this machine, tagged with the tool it came from.
|
|
200
301
|
function* iterateSessionObjects(env = process.env) {
|
|
201
|
-
|
|
202
|
-
|
|
302
|
+
const sources = toolSources(env);
|
|
303
|
+
// Phase 1: home-dir sources; collect the repo toplevels (cwds) they reveal.
|
|
304
|
+
const seenCwds = new Set();
|
|
305
|
+
for (const source of sources) {
|
|
306
|
+
if (source.repoLevel) continue;
|
|
307
|
+
const files = listSessionFiles(source.dir, source.fileFilter || isTranscriptFile);
|
|
203
308
|
for (const file of files) {
|
|
204
|
-
const objects =
|
|
309
|
+
const objects = readSourceObjects(source, file);
|
|
205
310
|
const cwd = resolveFileCwd(objects);
|
|
311
|
+
if (cwd) seenCwds.add(cwd);
|
|
206
312
|
for (const obj of objects) {
|
|
207
313
|
yield { tool: source.tool, obj, cwd, filePath: file };
|
|
208
314
|
}
|
|
209
315
|
}
|
|
210
316
|
}
|
|
317
|
+
// Phase 2: repo-level sources (e.g. Aider) over the repos just discovered — cwd is known.
|
|
318
|
+
for (const source of sources) {
|
|
319
|
+
if (!source.repoLevel) continue;
|
|
320
|
+
for (const top of seenCwds) {
|
|
321
|
+
const objects = readRepoLevelObjects(source, top);
|
|
322
|
+
for (const obj of objects) {
|
|
323
|
+
yield { tool: source.tool, obj, cwd: top, filePath: path.join(top, source.file) };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
211
327
|
}
|
|
212
328
|
|
|
213
329
|
// Scan every known AI-tool session directory on this machine.
|
|
@@ -303,13 +419,19 @@ function makeSessionsAccumulator() {
|
|
|
303
419
|
|
|
304
420
|
function walkSessionSignals(env = process.env, selectedCwds = null, onHumanObject = null) {
|
|
305
421
|
const sessions = makeSessionsAccumulator();
|
|
306
|
-
|
|
307
|
-
|
|
422
|
+
const sources = toolSources(env);
|
|
423
|
+
const seenCwds = new Set();
|
|
424
|
+
// Phase 1: home-dir sources; remember the repo toplevels (cwds) they reveal.
|
|
425
|
+
for (const source of sources) {
|
|
426
|
+
if (source.repoLevel) continue;
|
|
427
|
+
const files = listSessionFiles(source.dir, source.fileFilter || isTranscriptFile);
|
|
308
428
|
if (files.length === 0) continue;
|
|
309
429
|
sessions.startTool(source.tool);
|
|
310
430
|
for (const f of files) {
|
|
311
|
-
const objects =
|
|
312
|
-
|
|
431
|
+
const objects = readSourceObjects(source, f);
|
|
432
|
+
const cwd = resolveFileCwd(objects);
|
|
433
|
+
if (cwd) seenCwds.add(cwd);
|
|
434
|
+
if (!fileInScope(cwd, selectedCwds)) continue;
|
|
313
435
|
sessions.addFile(f, objects);
|
|
314
436
|
if (onHumanObject) {
|
|
315
437
|
for (const o of objects) onHumanObject(o);
|
|
@@ -317,6 +439,23 @@ function walkSessionSignals(env = process.env, selectedCwds = null, onHumanObjec
|
|
|
317
439
|
}
|
|
318
440
|
sessions.endTool();
|
|
319
441
|
}
|
|
442
|
+
// Phase 2: repo-level sources (e.g. Aider) — candidate repos = the selected scope, else
|
|
443
|
+
// the repos seen in phase 1. cwd is the repo toplevel, so attribution is exact.
|
|
444
|
+
const toplevels = selectedCwds instanceof Set ? selectedCwds : seenCwds;
|
|
445
|
+
for (const source of sources) {
|
|
446
|
+
if (!source.repoLevel) continue;
|
|
447
|
+
sessions.startTool(source.tool);
|
|
448
|
+
for (const top of toplevels) {
|
|
449
|
+
if (!fileInScope(top, selectedCwds)) continue;
|
|
450
|
+
const objects = readRepoLevelObjects(source, top);
|
|
451
|
+
if (objects.length === 0) continue;
|
|
452
|
+
sessions.addFile(path.join(top, source.file), objects);
|
|
453
|
+
if (onHumanObject) {
|
|
454
|
+
for (const o of objects) onHumanObject(o);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
sessions.endTool();
|
|
458
|
+
}
|
|
320
459
|
return sessions.result();
|
|
321
460
|
}
|
|
322
461
|
|
package/src/signup-client.js
CHANGED
|
@@ -29,36 +29,48 @@ function buildRegistrationContext({ preferredLanguage, newsletterConsent, freela
|
|
|
29
29
|
return context;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
// Hub refuses these claims with a coded error and creates nothing, so registering without the code is safe.
|
|
33
|
+
const UNCLAIMABLE_CODES = new Set(['auth-engine.claim_code_invalid', 'users.unregistered_talent_not_claimable']);
|
|
34
|
+
|
|
35
|
+
function codeFrom(raw) {
|
|
36
|
+
const body = parseJson(raw);
|
|
37
|
+
return body && typeof body.code === 'string' ? body.code : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function postCompleteRegistration(body, { endpoint, headers, timeoutMs }) {
|
|
41
41
|
let complete;
|
|
42
42
|
try {
|
|
43
|
-
complete = await postJsonWithTimeout(
|
|
44
|
-
endpoint,
|
|
45
|
-
{ userType: 'WORKS_TALENT', context },
|
|
46
|
-
timeoutMs,
|
|
47
|
-
'POST',
|
|
48
|
-
null,
|
|
49
|
-
Object.keys(headers).length ? headers : null,
|
|
50
|
-
);
|
|
43
|
+
complete = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, Object.keys(headers).length ? headers : null);
|
|
51
44
|
} catch (e) {
|
|
52
45
|
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
53
46
|
}
|
|
54
47
|
if (complete.status < 200 || complete.status >= 300) {
|
|
55
|
-
return { ok: false, reason: `complete-registration-http-${complete.status}`, status: complete.status, message: messageFrom(complete.raw) };
|
|
48
|
+
return { ok: false, reason: `complete-registration-http-${complete.status}`, status: complete.status, code: codeFrom(complete.raw), message: messageFrom(complete.raw) };
|
|
56
49
|
}
|
|
57
50
|
return { ok: true };
|
|
58
51
|
}
|
|
59
52
|
|
|
53
|
+
// Shared complete-registration call. With a claimCode it claims the MCP sign-up's UNREGISTERED profile; an unclaimable code still registers the account, with claimed:false.
|
|
54
|
+
async function requestCompleteRegistration(
|
|
55
|
+
context = {},
|
|
56
|
+
{ endpoint, cookie = null, bearerToken = null, claimCode = null, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
|
|
57
|
+
) {
|
|
58
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
59
|
+
const headers = {};
|
|
60
|
+
if (cookie) headers.Cookie = cookie;
|
|
61
|
+
if (bearerToken) headers.Authorization = `Bearer ${bearerToken}`;
|
|
62
|
+
const base = { userType: 'WORKS_TALENT', context };
|
|
63
|
+
if (typeof claimCode !== 'string' || !claimCode) return postCompleteRegistration(base, { endpoint, headers, timeoutMs });
|
|
64
|
+
|
|
65
|
+
const claimed = await postCompleteRegistration({ ...base, claimCode }, { endpoint, headers, timeoutMs });
|
|
66
|
+
if (claimed.ok) return { ok: true, claimed: true };
|
|
67
|
+
if (!UNCLAIMABLE_CODES.has(claimed.code)) return claimed;
|
|
68
|
+
const plain = await postCompleteRegistration(base, { endpoint, headers, timeoutMs });
|
|
69
|
+
return plain.ok ? { ok: true, claimed: false, claimError: claimed.code } : plain;
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
async function requestSignUp(
|
|
61
|
-
{ name, lastName, email, password, preferredLanguage, newsletterConsent, freelanceType, freelanceIntent } = {},
|
|
73
|
+
{ name, lastName, email, password, preferredLanguage, newsletterConsent, freelanceType, freelanceIntent, claimCode = null } = {},
|
|
62
74
|
{ signUpEndpoint, completeRegistrationEndpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
|
|
63
75
|
) {
|
|
64
76
|
if (!signUpEndpoint) return { ok: false, reason: 'no-endpoint' };
|
|
@@ -83,9 +95,9 @@ async function requestSignUp(
|
|
|
83
95
|
if (!completeRegistrationEndpoint) return { ok: false, reason: 'no-endpoint' };
|
|
84
96
|
|
|
85
97
|
const context = buildRegistrationContext({ preferredLanguage, newsletterConsent, freelanceType, freelanceIntent });
|
|
86
|
-
const complete = await requestCompleteRegistration(context, { endpoint: completeRegistrationEndpoint, cookie, timeoutMs });
|
|
98
|
+
const complete = await requestCompleteRegistration(context, { endpoint: completeRegistrationEndpoint, cookie, claimCode, timeoutMs });
|
|
87
99
|
if (!complete.ok) return complete;
|
|
88
|
-
return { ok: true, accountExists: false };
|
|
100
|
+
return claimCode ? { ok: true, accountExists: false, claimed: complete.claimed === true } : { ok: true, accountExists: false };
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
module.exports = { requestSignUp, requestCompleteRegistration, buildRegistrationContext };
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Talent-facing copy of the MCP sign-up (ticket 39540, UX review 39574): fixed text the AI shows word for word, in the talent's language.
|
|
4
|
+
|
|
5
|
+
const SIGNUP_LANGUAGES = ['es', 'en', 'it', 'pt'];
|
|
6
|
+
|
|
7
|
+
// Legal texts. PENDING legal/GDPR validation: not final copy. The CLI onboarding reads the es/en ones too.
|
|
8
|
+
const LEGAL = {
|
|
9
|
+
es: {
|
|
10
|
+
signupNotice: 'Antes de crear tu cuenta: guardamos los datos que me des (tu LinkedIn, tu CV y lo que sé de ti) y podemos contactarte. Te completo el perfil automáticamente y cualquier campo lo puedes cambiar después en la web. La entrevista de onboarding la puedes hacer luego aquí mismo en el chat o en la web.',
|
|
11
|
+
usageInfoAccessed: 'Se accede a: la estructura de tus herramientas de IA y a extractos anonimizados de tu actividad en este equipo. Tu código y tus prompts en crudo NUNCA salen de tu máquina.',
|
|
12
|
+
usageGoalDuration: 'Objetivo: medir cómo trabajas con IA para tu perfil. Duración: ~1-2 minutos.',
|
|
13
|
+
interviewInfoAccessed: 'Se accede a: tus respuestas en esta entrevista y tu perfil importado. La conduce el servidor de Shakers.',
|
|
14
|
+
interviewGoalDuration: 'Objetivo: conocer tu rol, tu dedicación y cómo usas la IA. Duración: ~5 minutos.',
|
|
15
|
+
},
|
|
16
|
+
en: {
|
|
17
|
+
signupNotice: 'Before we create your account: we store the data you give me (your LinkedIn, your CV and what I know about you) and may contact you. I fill in your profile automatically and you can change any field later on the web. You can do the onboarding interview afterwards right here in the chat or on the web.',
|
|
18
|
+
usageInfoAccessed: 'Accessed: the structure of your AI tools and redacted excerpts of your activity on this machine. Your raw code and prompts NEVER leave your machine.',
|
|
19
|
+
usageGoalDuration: 'Goal: measure how you work with AI for your profile. Duration: ~1-2 minutes.',
|
|
20
|
+
interviewInfoAccessed: 'Accessed: your answers in this interview and your imported profile. It is conducted by the Shakers server.',
|
|
21
|
+
interviewGoalDuration: 'Goal: learn your role, your dedication and how you use AI. Duration: ~5 minutes.',
|
|
22
|
+
},
|
|
23
|
+
it: {
|
|
24
|
+
signupNotice: 'Prima di creare il tuo account: conserviamo i dati che mi dai (il tuo LinkedIn, il tuo CV e quello che so di te) e possiamo contattarti. Completo il tuo profilo in automatico e puoi cambiare qualsiasi campo in seguito sul sito. Il colloquio di onboarding puoi farlo dopo qui nella chat o sul sito.',
|
|
25
|
+
usageInfoAccessed: 'Accesso a: la struttura dei tuoi strumenti di IA ed estratti anonimizzati della tua attività su questo computer. Il tuo codice e i tuoi prompt originali NON escono MAI dalla tua macchina.',
|
|
26
|
+
usageGoalDuration: "Obiettivo: misurare come lavori con l'IA per il tuo profilo. Durata: ~1-2 minuti.",
|
|
27
|
+
interviewInfoAccessed: 'Accesso a: le tue risposte in questo colloquio e il tuo profilo importato. Lo conduce il server di Shakers.',
|
|
28
|
+
interviewGoalDuration: "Obiettivo: conoscere il tuo ruolo, la tua disponibilità e come usi l'IA. Durata: ~5 minuti.",
|
|
29
|
+
},
|
|
30
|
+
pt: {
|
|
31
|
+
signupNotice: 'Antes de criar a sua conta: guardamos os dados que me der (o seu LinkedIn, o seu CV e o que sei sobre si) e podemos contactá-lo. Preencho o seu perfil automaticamente e pode alterar qualquer campo depois na web. A entrevista de onboarding pode fazê-la depois aqui mesmo no chat ou na web.',
|
|
32
|
+
usageInfoAccessed: 'Acede-se a: a estrutura das suas ferramentas de IA e excertos anonimizados da sua atividade neste computador. O seu código e os seus prompts em bruto NUNCA saem da sua máquina.',
|
|
33
|
+
usageGoalDuration: 'Objetivo: medir como trabalha com IA para o seu perfil. Duração: ~1-2 minutos.',
|
|
34
|
+
interviewInfoAccessed: 'Acede-se a: as suas respostas nesta entrevista e o seu perfil importado. É conduzida pelo servidor da Shakers.',
|
|
35
|
+
interviewGoalDuration: 'Objetivo: conhecer o seu perfil, a sua dedicação e como usa a IA. Duração: ~5 minutos.',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const LOCALES = { es: 'es-ES', en: 'en-GB', it: 'it-IT', pt: 'pt-PT' };
|
|
40
|
+
|
|
41
|
+
function joinParts(parts) {
|
|
42
|
+
return parts.filter((p) => p !== null && p !== undefined && String(p).trim() !== '').join(' · ');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function amount(lang, value) {
|
|
46
|
+
return new Intl.NumberFormat(LOCALES[lang], { maximumFractionDigits: 0 }).format(Number(value));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const COPY = {
|
|
50
|
+
es: {
|
|
51
|
+
welcome: (name) => `¡Hola${name ? `, ${name}` : ''}! Gracias por querer unirte a Shakers 🙌\n\nSomos una comunidad de profesionales tech freelance. Nuestro objetivo es tener siempre tu versión más actual como profesional (lo que sabes hacer hoy, cómo trabajas con IA y qué buscas) para conectarte con proyectos que de verdad encajen contigo.\n\nEl alta son tres pasos y la mayor parte la hago yo por ti:\n1. Tu perfil (2 min). Lo relleno a partir de tu CV y LinkedIn, tú solo revisas y confirmas.\n2. Cómo trabajas con IA (1-2 min, opcional). Analizo qué herramientas usas en este equipo. Tu código y tus prompts nunca salen de tu máquina.\n3. Una breve entrevista (opcional). Para afinar tu rol y lo que buscas. Puedes hacerla ahora o más tarde.\n\nTodo lo puedes editar después desde la web.`,
|
|
52
|
+
welcomeQuestion: '¿Empezamos? Si me das permiso, busco tu CV en este equipo y te preparo un borrador para que solo tengas que confirmarlo.',
|
|
53
|
+
cvSearch: 'Sí, búscalo',
|
|
54
|
+
cvAttach: 'Te lo adjunto yo',
|
|
55
|
+
cvAttachAsk: 'Perfecto, adjúntamelo aquí.',
|
|
56
|
+
cvNotFound: 'No he encontrado tu CV en este equipo. ¿Me lo adjuntas aquí?',
|
|
57
|
+
linkedinAsk: '¿Me pasas tu perfil de LinkedIn?',
|
|
58
|
+
linkedinOpening: 'Te abro tu perfil de LinkedIn en el navegador para que copies la dirección.',
|
|
59
|
+
linkedinOpened: 'Te he abierto tu perfil de LinkedIn; copia la dirección de la barra y pégamela aquí.',
|
|
60
|
+
emailKnown: (email) => `¿Te registro con ${email}? Usa el email que más mires: ahí te llegarán los proyectos que encajen contigo y las novedades de Shakers.`,
|
|
61
|
+
emailUnknown: '¿Con qué email te registro? Usa el que más mires: ahí te llegarán los proyectos que encajen contigo y las novedades de Shakers.',
|
|
62
|
+
emailWorkHint: 'Si es el de tu trabajo actual, quizá prefieras uno personal para no perder el acceso si cambias de empresa.',
|
|
63
|
+
emailYes: 'Sí, ese',
|
|
64
|
+
emailOther: 'Usar otro',
|
|
65
|
+
draftTitle: 'Esto es lo que he preparado para tu perfil:',
|
|
66
|
+
years: (n) => `${n} años de experiencia`,
|
|
67
|
+
hours: (h) => `${h} h/mes`,
|
|
68
|
+
rates: (hourly, annual) => `Tarifa estimada: ${joinParts([hourly != null ? `${amount('es', hourly)} €/h` : null, annual != null ? `${amount('es', annual)} €/año` : null])} (estimación mía, ajústala)`,
|
|
69
|
+
draftQuestion: '¿Está bien o cambio algo?',
|
|
70
|
+
draftOk: 'Todo correcto',
|
|
71
|
+
draftChange: 'Quiero cambiar algo',
|
|
72
|
+
windowOpening: 'Perfecto. Ahora solo falta crear tu cuenta para guardar todo esto y que puedas entrar en Shakers cuando quieras.\n\nTe abro una ventana en el navegador, elige cómo entrar:\n· Con Google\n· Con email y contraseña\n\nLa contraseña la escribes allí directamente, nunca pasa por este chat. En esa misma pantalla aceptas los términos y la política de privacidad.\n\n👉 Mira tu navegador. Cuando termines, vuelve aquí y seguimos.',
|
|
73
|
+
windowLink: (link) => `Si no se te ha abierto la ventana, entra desde aquí: ${link}`,
|
|
74
|
+
existingTitle: 'He visto que ya tienes cuenta. ¿Actualizo tu perfil con esto?',
|
|
75
|
+
existingRate: (from, to) => `Tarifa por hora: ${from} → ${to}`,
|
|
76
|
+
existingAnnual: (from, to) => `Tarifa anual: ${from} → ${to}`,
|
|
77
|
+
existingHours: (from, to) => `Disponibilidad: ${from} → ${to}`,
|
|
78
|
+
existingEmpty: 'sin indicar',
|
|
79
|
+
existingLists: 'Añado lo nuevo de tu CV y tu LinkedIn (experiencias, proyectos, formación) y relleno lo que tengas vacío, sin borrar nada.',
|
|
80
|
+
existingYes: 'Sí, actualízalo',
|
|
81
|
+
existingNo: 'No, déjalo como está',
|
|
82
|
+
existingKept: 'Perfecto, dejo tu perfil como está.',
|
|
83
|
+
aiUsageQuestion: '¿Quieres que analice cómo trabajas con IA? Es opcional y tarda 1-2 min. Solo veo qué herramientas usas, tu código y tus prompts no salen de tu máquina. Te ayuda a destacar en proyectos de IA.',
|
|
84
|
+
aiUsageYes: 'Sí, analízalo',
|
|
85
|
+
aiUsageSkip: 'Saltar este paso',
|
|
86
|
+
aiUsageSkipped: "Sin problema, seguimos sin él. Te aviso de que es lo que más pesa para que te lleguen proyectos: lo puedes lanzar cuando quieras diciéndome 'analiza mi uso de IA'.",
|
|
87
|
+
interviewWhereQuestion: '¿Hacemos la entrevista de onboarding aquí ahora o luego en la web?',
|
|
88
|
+
interviewHere: 'Aquí ahora',
|
|
89
|
+
interviewLater: 'Luego en la web',
|
|
90
|
+
answerModeQuestion: '¿Te propongo yo las respuestas con lo que sé de ti, o las contestas tú?',
|
|
91
|
+
answerModeDrafted: 'Propónmelas tú',
|
|
92
|
+
answerModeOwn: 'Las contesto yo',
|
|
93
|
+
answerModeFull: 'Hazla entera',
|
|
94
|
+
livekitUnavailable: 'La entrevista no puede arrancar en este equipo. Puedes hacerla en la web: te abro Shakers con tu sesión iniciada.',
|
|
95
|
+
rolesIntro: 'Con todo lo que me has contado, encajas en estos perfiles:',
|
|
96
|
+
roleRecommended: (name) => `**${name}** (recomendado)`,
|
|
97
|
+
roleName: (name) => `**${name}**`,
|
|
98
|
+
roleReason: { IMPORT: 'Por tu CV y tu LinkedIn', ONBOARDING_INTERVIEW: 'Por lo que me contaste en la entrevista', MATCH: 'Por tu perfil en Shakers' },
|
|
99
|
+
openProjects: (n) => (n === 1 ? 'Ahora mismo hay 1 proyecto abierto que busca este perfil.' : `Ahora mismo hay ${n} proyectos abiertos que buscan este perfil.`),
|
|
100
|
+
rolesOutro: 'Tu perfil principal es el que ven primero las empresas y el que usamos para recomendarte proyectos. Puedes añadir más y cambiarlo cuando quieras.',
|
|
101
|
+
mainRoleQuestion: '¿Cuál es tu perfil principal?',
|
|
102
|
+
mainRoleOther: 'Otro rol del catálogo',
|
|
103
|
+
hourlyRateLabel: 'Tarifa por hora',
|
|
104
|
+
annualRateLabel: 'Tarifa anual',
|
|
105
|
+
perHour: '€/h',
|
|
106
|
+
perYear: '€/año',
|
|
107
|
+
ratesSaved: (rates) => `${rates.join(' · ')}. Es una estimación: la puedes cambiar en la web.`,
|
|
108
|
+
saved: { workSituation: 'Situación laboral guardada.', availability: 'Disponibilidad guardada.', languages: 'Idiomas guardados.', phone: 'Teléfono guardado.' },
|
|
109
|
+
yes: 'Sí',
|
|
110
|
+
no: 'No',
|
|
111
|
+
},
|
|
112
|
+
en: {
|
|
113
|
+
welcome: (name) => `Hi${name ? `, ${name}` : ''}! Thanks for wanting to join Shakers 🙌\n\nWe are a community of freelance tech professionals. Our goal is to always have the most up-to-date version of you as a professional (what you can do today, how you work with AI and what you are looking for) to connect you with projects that truly fit you.\n\nSigning up takes three steps and I do most of it for you:\n1. Your profile (2 min). I fill it in from your CV and LinkedIn; you just review and confirm.\n2. How you work with AI (1-2 min, optional). I look at which tools you use on this computer. Your code and your prompts never leave your machine.\n3. A short interview (optional). To fine-tune your role and what you are looking for. You can do it now or later.\n\nYou can edit everything later on the web.`,
|
|
114
|
+
welcomeQuestion: "Shall we start? With your permission, I'll look for your CV on this computer and prepare a draft so you only have to confirm it.",
|
|
115
|
+
cvSearch: 'Yes, look for it',
|
|
116
|
+
cvAttach: "I'll attach it",
|
|
117
|
+
cvAttachAsk: 'Great, attach it here.',
|
|
118
|
+
cvNotFound: "I couldn't find your CV on this computer. Could you attach it here?",
|
|
119
|
+
linkedinAsk: 'Could you send me your LinkedIn profile?',
|
|
120
|
+
linkedinOpening: 'I am opening your LinkedIn profile in your browser so you can copy its address.',
|
|
121
|
+
linkedinOpened: 'I have opened your LinkedIn profile; copy the address from the address bar and paste it here.',
|
|
122
|
+
emailKnown: (email) => `Shall I sign you up with ${email}? Use the email you check most: that is where the projects that fit you and Shakers news will arrive.`,
|
|
123
|
+
emailUnknown: 'Which email shall I sign you up with? Use the one you check most: that is where the projects that fit you and Shakers news will arrive.',
|
|
124
|
+
emailWorkHint: 'If it is your current work email, you may prefer a personal one so you do not lose access if you change companies.',
|
|
125
|
+
emailYes: 'Yes, that one',
|
|
126
|
+
emailOther: 'Use another one',
|
|
127
|
+
draftTitle: 'Here is what I have prepared for your profile:',
|
|
128
|
+
years: (n) => `${n} years of experience`,
|
|
129
|
+
hours: (h) => `${h} h/month`,
|
|
130
|
+
rates: (hourly, annual) => `Estimated rate: ${joinParts([hourly != null ? `${amount('en', hourly)} €/h` : null, annual != null ? `${amount('en', annual)} €/year` : null])} (my estimate, adjust it)`,
|
|
131
|
+
draftQuestion: 'Does it look right, or shall I change something?',
|
|
132
|
+
draftOk: 'All correct',
|
|
133
|
+
draftChange: 'I want to change something',
|
|
134
|
+
windowOpening: "Great. Now all that's left is to create your account, to save all this and so you can sign in to Shakers whenever you like.\n\nI'm opening a window in your browser; choose how to sign in:\n· With Google\n· With email and password\n\nYou type the password there directly; it never goes through this chat. On that same screen you accept the terms and the privacy policy.\n\n👉 Check your browser. When you're done, come back here and we'll carry on.",
|
|
135
|
+
windowLink: (link) => `If the window did not open, use this link: ${link}`,
|
|
136
|
+
existingTitle: 'I see you already have an account. Shall I update your profile with this?',
|
|
137
|
+
existingRate: (from, to) => `Hourly rate: ${from} → ${to}`,
|
|
138
|
+
existingAnnual: (from, to) => `Annual rate: ${from} → ${to}`,
|
|
139
|
+
existingHours: (from, to) => `Availability: ${from} → ${to}`,
|
|
140
|
+
existingEmpty: 'not set',
|
|
141
|
+
existingLists: 'I add what is new from your CV and LinkedIn (experience, projects, education) and fill in what is empty, without deleting anything.',
|
|
142
|
+
existingYes: 'Yes, update it',
|
|
143
|
+
existingNo: 'No, leave it as it is',
|
|
144
|
+
existingKept: 'Sure, I am leaving your profile as it is.',
|
|
145
|
+
aiUsageQuestion: 'Do you want me to analyse how you work with AI? It is optional and takes 1-2 min. I only see which tools you use; your code and your prompts do not leave your machine. It helps you stand out for AI projects.',
|
|
146
|
+
aiUsageYes: 'Yes, analyse it',
|
|
147
|
+
aiUsageSkip: 'Skip this step',
|
|
148
|
+
aiUsageSkipped: "No problem, we carry on without it. Heads-up: it is what weighs most for projects to reach you. You can run it whenever you like by telling me 'analyse my AI usage'.",
|
|
149
|
+
interviewWhereQuestion: 'Shall we do the onboarding interview here now or later on the web?',
|
|
150
|
+
interviewHere: 'Here now',
|
|
151
|
+
interviewLater: 'Later on the web',
|
|
152
|
+
answerModeQuestion: 'Shall I draft your answers from what I know about you, or will you answer yourself?',
|
|
153
|
+
answerModeDrafted: 'Draft them for me',
|
|
154
|
+
answerModeOwn: "I'll answer myself",
|
|
155
|
+
answerModeFull: 'Do it all for me',
|
|
156
|
+
livekitUnavailable: 'The interview cannot start on this machine. You can do it on the web: I will open Shakers with you signed in.',
|
|
157
|
+
rolesIntro: 'With everything you have told me, you fit these profiles:',
|
|
158
|
+
roleRecommended: (name) => `**${name}** (recommended)`,
|
|
159
|
+
roleName: (name) => `**${name}**`,
|
|
160
|
+
roleReason: { IMPORT: 'From your CV and LinkedIn', ONBOARDING_INTERVIEW: 'From what you told me in the interview', MATCH: 'From your Shakers profile' },
|
|
161
|
+
openProjects: (n) => (n === 1 ? 'Right now there is 1 open project looking for this profile.' : `Right now there are ${n} open projects looking for this profile.`),
|
|
162
|
+
rolesOutro: 'Your main profile is the one companies see first and the one we use to recommend projects to you. You can add more and change it whenever you like.',
|
|
163
|
+
mainRoleQuestion: 'Which is your main profile?',
|
|
164
|
+
mainRoleOther: 'Another role from the catalogue',
|
|
165
|
+
hourlyRateLabel: 'Hourly rate',
|
|
166
|
+
annualRateLabel: 'Annual rate',
|
|
167
|
+
perHour: '€/hour',
|
|
168
|
+
perYear: '€/year',
|
|
169
|
+
ratesSaved: (rates) => `${rates.join(' · ')}. It is an estimate: you can change it on the web.`,
|
|
170
|
+
saved: { workSituation: 'Work situation saved.', availability: 'Availability saved.', languages: 'Languages saved.', phone: 'Phone saved.' },
|
|
171
|
+
yes: 'Yes',
|
|
172
|
+
no: 'No',
|
|
173
|
+
},
|
|
174
|
+
it: {
|
|
175
|
+
welcome: (name) => `Ciao${name ? `, ${name}` : ''}! Grazie per voler entrare in Shakers 🙌\n\nSiamo una community di professionisti tech freelance. Il nostro obiettivo è avere sempre la tua versione più aggiornata come professionista (cosa sai fare oggi, come lavori con l'IA e cosa cerchi) per metterti in contatto con progetti che fanno davvero per te.\n\nL'iscrizione richiede tre passaggi e la maggior parte la faccio io per te:\n1. Il tuo profilo (2 min). Lo compilo a partire dal tuo CV e da LinkedIn, tu devi solo rivederlo e confermarlo.\n2. Come lavori con l'IA (1-2 min, facoltativo). Analizzo quali strumenti usi su questo computer. Il tuo codice e i tuoi prompt non escono mai dalla tua macchina.\n3. Un breve colloquio (facoltativo). Per definire meglio il tuo ruolo e cosa cerchi. Puoi farlo ora o più tardi.\n\nPuoi modificare tutto in seguito dal sito.`,
|
|
176
|
+
welcomeQuestion: 'Iniziamo? Se mi dai il permesso, cerco il tuo CV su questo computer e ti preparo una bozza da confermare e basta.',
|
|
177
|
+
cvSearch: 'Sì, cercalo',
|
|
178
|
+
cvAttach: 'Te lo allego io',
|
|
179
|
+
cvAttachAsk: 'Perfetto, allegalo qui.',
|
|
180
|
+
cvNotFound: 'Non ho trovato il tuo CV su questo computer. Me lo alleghi qui?',
|
|
181
|
+
linkedinAsk: 'Mi mandi il tuo profilo LinkedIn?',
|
|
182
|
+
linkedinOpening: "Ti apro il tuo profilo LinkedIn nel browser così puoi copiarne l'indirizzo.",
|
|
183
|
+
linkedinOpened: "Ti ho aperto il tuo profilo LinkedIn; copia l'indirizzo dalla barra e incollalo qui.",
|
|
184
|
+
emailKnown: (email) => `Ti registro con ${email}? Usa l'email che controlli di più: lì ti arriveranno i progetti adatti a te e le novità di Shakers.`,
|
|
185
|
+
emailUnknown: "Con quale email ti registro? Usa quella che controlli di più: lì ti arriveranno i progetti adatti a te e le novità di Shakers.",
|
|
186
|
+
emailWorkHint: "Se è quella del tuo lavoro attuale, forse ne preferisci una personale per non perdere l'accesso se cambi azienda.",
|
|
187
|
+
emailYes: 'Sì, quella',
|
|
188
|
+
emailOther: "Usane un'altra",
|
|
189
|
+
draftTitle: 'Ecco cosa ho preparato per il tuo profilo:',
|
|
190
|
+
years: (n) => `${n} anni di esperienza`,
|
|
191
|
+
hours: (h) => `${h} h/mese`,
|
|
192
|
+
rates: (hourly, annual) => `Tariffa stimata: ${joinParts([hourly != null ? `${amount('it', hourly)} €/h` : null, annual != null ? `${amount('it', annual)} €/anno` : null])} (una mia stima, modificala pure)`,
|
|
193
|
+
draftQuestion: 'Va bene o cambio qualcosa?',
|
|
194
|
+
draftOk: 'Tutto corretto',
|
|
195
|
+
draftChange: 'Voglio cambiare qualcosa',
|
|
196
|
+
windowOpening: "Perfetto. Ora manca solo creare il tuo account per salvare tutto questo e poter entrare in Shakers quando vuoi.\n\nTi apro una finestra nel browser, scegli come entrare:\n· Con Google\n· Con email e password\n\nLa password la scrivi direttamente lì, non passa mai da questa chat. Nella stessa schermata accetti i termini e l'informativa sulla privacy.\n\n👉 Guarda il tuo browser. Quando hai finito, torna qui e continuiamo.",
|
|
197
|
+
windowLink: (link) => `Se la finestra non si è aperta, entra da qui: ${link}`,
|
|
198
|
+
existingTitle: 'Ho visto che hai già un account. Aggiorno il tuo profilo con questi dati?',
|
|
199
|
+
existingRate: (from, to) => `Tariffa oraria: ${from} → ${to}`,
|
|
200
|
+
existingAnnual: (from, to) => `Tariffa annuale: ${from} → ${to}`,
|
|
201
|
+
existingHours: (from, to) => `Disponibilità: ${from} → ${to}`,
|
|
202
|
+
existingEmpty: 'non indicata',
|
|
203
|
+
existingLists: 'Aggiungo le novità del tuo CV e di LinkedIn (esperienze, progetti, formazione) e compilo quello che hai vuoto, senza cancellare nulla.',
|
|
204
|
+
existingYes: 'Sì, aggiornalo',
|
|
205
|
+
existingNo: 'No, lascialo così',
|
|
206
|
+
existingKept: 'Perfetto, lascio il tuo profilo così com’è.',
|
|
207
|
+
aiUsageQuestion: "Vuoi che analizzi come lavori con l'IA? È facoltativo e richiede 1-2 min. Vedo solo quali strumenti usi, il tuo codice e i tuoi prompt non escono dalla tua macchina. Ti aiuta a distinguerti nei progetti di IA.",
|
|
208
|
+
aiUsageYes: 'Sì, analizzalo',
|
|
209
|
+
aiUsageSkip: 'Salta questo passaggio',
|
|
210
|
+
aiUsageSkipped: "Nessun problema, andiamo avanti senza. Ti avviso che è ciò che pesa di più perché ti arrivino progetti: puoi lanciarlo quando vuoi dicendomi 'analizza il mio uso dell'IA'.",
|
|
211
|
+
interviewWhereQuestion: 'Facciamo il colloquio di onboarding qui adesso o più tardi sul sito?',
|
|
212
|
+
interviewHere: 'Qui adesso',
|
|
213
|
+
interviewLater: 'Più tardi sul sito',
|
|
214
|
+
answerModeQuestion: 'Ti propongo io le risposte con quello che so di te, o rispondi tu?',
|
|
215
|
+
answerModeDrafted: 'Proponile tu',
|
|
216
|
+
answerModeOwn: 'Rispondo io',
|
|
217
|
+
answerModeFull: 'Fallo tutto tu',
|
|
218
|
+
livekitUnavailable: 'Il colloquio non può partire su questo computer. Puoi farlo sul sito: ti apro Shakers con la sessione già avviata.',
|
|
219
|
+
rolesIntro: 'Con tutto quello che mi hai raccontato, sei adatto a questi profili:',
|
|
220
|
+
roleRecommended: (name) => `**${name}** (consigliato)`,
|
|
221
|
+
roleName: (name) => `**${name}**`,
|
|
222
|
+
roleReason: { IMPORT: 'Dal tuo CV e da LinkedIn', ONBOARDING_INTERVIEW: 'Da quello che mi hai raccontato nel colloquio', MATCH: 'Dal tuo profilo su Shakers' },
|
|
223
|
+
openProjects: (n) => (n === 1 ? "In questo momento c'è 1 progetto aperto che cerca questo profilo." : `In questo momento ci sono ${n} progetti aperti che cercano questo profilo.`),
|
|
224
|
+
rolesOutro: 'Il tuo profilo principale è quello che le aziende vedono per primo e quello che usiamo per consigliarti progetti. Puoi aggiungerne altri e cambiarlo quando vuoi.',
|
|
225
|
+
mainRoleQuestion: 'Qual è il tuo profilo principale?',
|
|
226
|
+
mainRoleOther: 'Un altro ruolo del catalogo',
|
|
227
|
+
hourlyRateLabel: 'Tariffa oraria',
|
|
228
|
+
annualRateLabel: 'Tariffa annuale',
|
|
229
|
+
perHour: '€/h',
|
|
230
|
+
perYear: '€/anno',
|
|
231
|
+
ratesSaved: (rates) => `${rates.join(' · ')}. È una stima: puoi cambiarla sul sito.`,
|
|
232
|
+
saved: { workSituation: 'Situazione lavorativa salvata.', availability: 'Disponibilità salvata.', languages: 'Lingue salvate.', phone: 'Telefono salvato.' },
|
|
233
|
+
yes: 'Sì',
|
|
234
|
+
no: 'No',
|
|
235
|
+
},
|
|
236
|
+
pt: {
|
|
237
|
+
welcome: (name) => `Olá${name ? `, ${name}` : ''}! Obrigado por querer juntar-se à Shakers 🙌\n\nSomos uma comunidade de profissionais tech freelance. O nosso objetivo é ter sempre a sua versão mais atual como profissional (o que sabe fazer hoje, como trabalha com IA e o que procura) para o pôr em contacto com projetos que encaixem mesmo consigo.\n\nO registo tem três passos e a maior parte faço eu por si:\n1. O seu perfil (2 min). Preencho-o a partir do seu CV e do LinkedIn; só tem de rever e confirmar.\n2. Como trabalha com IA (1-2 min, opcional). Analiso que ferramentas usa neste computador. O seu código e os seus prompts nunca saem da sua máquina.\n3. Uma breve entrevista (opcional). Para afinar o seu perfil e o que procura. Pode fazê-la agora ou mais tarde.\n\nPode editar tudo depois na web.`,
|
|
238
|
+
welcomeQuestion: 'Começamos? Se me der autorização, procuro o seu CV neste computador e preparo um rascunho para que só tenha de o confirmar.',
|
|
239
|
+
cvSearch: 'Sim, procure-o',
|
|
240
|
+
cvAttach: 'Eu anexo-o',
|
|
241
|
+
cvAttachAsk: 'Perfeito, anexe-o aqui.',
|
|
242
|
+
cvNotFound: 'Não encontrei o seu CV neste computador. Pode anexá-lo aqui?',
|
|
243
|
+
linkedinAsk: 'Pode enviar-me o seu perfil do LinkedIn?',
|
|
244
|
+
linkedinOpening: 'Vou abrir o seu perfil do LinkedIn no navegador para que copie o endereço.',
|
|
245
|
+
linkedinOpened: 'Abri o seu perfil do LinkedIn; copie o endereço da barra e cole-o aqui.',
|
|
246
|
+
emailKnown: (email) => `Registo-o com ${email}? Use o email que consulta mais: é aí que vai receber os projetos que encaixam consigo e as novidades da Shakers.`,
|
|
247
|
+
emailUnknown: 'Com que email o registo? Use o que consulta mais: é aí que vai receber os projetos que encaixam consigo e as novidades da Shakers.',
|
|
248
|
+
emailWorkHint: 'Se for o do seu trabalho atual, talvez prefira um pessoal para não perder o acesso se mudar de empresa.',
|
|
249
|
+
emailYes: 'Sim, esse',
|
|
250
|
+
emailOther: 'Usar outro',
|
|
251
|
+
draftTitle: 'Isto é o que preparei para o seu perfil:',
|
|
252
|
+
years: (n) => `${n} anos de experiência`,
|
|
253
|
+
hours: (h) => `${h} h/mês`,
|
|
254
|
+
rates: (hourly, annual) => `Tarifa estimada: ${joinParts([hourly != null ? `${amount('pt', hourly)} €/h` : null, annual != null ? `${amount('pt', annual)} €/ano` : null])} (estimativa minha, ajuste-a)`,
|
|
255
|
+
draftQuestion: 'Está bem ou mudo alguma coisa?',
|
|
256
|
+
draftOk: 'Está tudo certo',
|
|
257
|
+
draftChange: 'Quero mudar algo',
|
|
258
|
+
windowOpening: 'Perfeito. Agora só falta criar a sua conta para guardar tudo isto e poder entrar na Shakers quando quiser.\n\nVou abrir uma janela no seu navegador; escolha como entrar:\n· Com Google\n· Com email e palavra-passe\n\nEscreve a palavra-passe diretamente lá; nunca passa por este chat. Nesse mesmo ecrã aceita os termos e a política de privacidade.\n\n👉 Veja o seu navegador. Quando terminar, volte aqui e continuamos.',
|
|
259
|
+
windowLink: (link) => `Se a janela não abriu, entre por aqui: ${link}`,
|
|
260
|
+
existingTitle: 'Vi que já tem conta. Atualizo o seu perfil com isto?',
|
|
261
|
+
existingRate: (from, to) => `Tarifa por hora: ${from} → ${to}`,
|
|
262
|
+
existingAnnual: (from, to) => `Tarifa anual: ${from} → ${to}`,
|
|
263
|
+
existingHours: (from, to) => `Disponibilidade: ${from} → ${to}`,
|
|
264
|
+
existingEmpty: 'sem indicar',
|
|
265
|
+
existingLists: 'Acrescento o que há de novo no seu CV e no LinkedIn (experiências, projetos, formação) e preencho o que tiver vazio, sem apagar nada.',
|
|
266
|
+
existingYes: 'Sim, atualize',
|
|
267
|
+
existingNo: 'Não, deixe como está',
|
|
268
|
+
existingKept: 'Perfeito, deixo o seu perfil como está.',
|
|
269
|
+
aiUsageQuestion: 'Quer que analise como trabalha com IA? É opcional e demora 1-2 min. Só vejo que ferramentas usa; o seu código e os seus prompts não saem da sua máquina. Ajuda-o a destacar-se em projetos de IA.',
|
|
270
|
+
aiUsageYes: 'Sim, analise',
|
|
271
|
+
aiUsageSkip: 'Saltar este passo',
|
|
272
|
+
aiUsageSkipped: "Sem problema, seguimos sem ele. Aviso-o de que é o que mais pesa para lhe chegarem projetos: pode lançá-lo quando quiser dizendo-me 'analisa o meu uso de IA'.",
|
|
273
|
+
interviewWhereQuestion: 'Fazemos a entrevista de onboarding aqui agora ou mais tarde na web?',
|
|
274
|
+
interviewHere: 'Aqui agora',
|
|
275
|
+
interviewLater: 'Mais tarde na web',
|
|
276
|
+
answerModeQuestion: 'Proponho-lhe eu as respostas com o que sei sobre si, ou responde você?',
|
|
277
|
+
answerModeDrafted: 'Proponha-as você',
|
|
278
|
+
answerModeOwn: 'Respondo eu',
|
|
279
|
+
answerModeFull: 'Faça-a toda',
|
|
280
|
+
livekitUnavailable: 'A entrevista não pode começar neste computador. Pode fazê-la na web: abro-lhe a Shakers com a sessão iniciada.',
|
|
281
|
+
rolesIntro: 'Com tudo o que me contou, encaixa nestes perfis:',
|
|
282
|
+
roleRecommended: (name) => `**${name}** (recomendado)`,
|
|
283
|
+
roleName: (name) => `**${name}**`,
|
|
284
|
+
roleReason: { IMPORT: 'Pelo seu CV e pelo LinkedIn', ONBOARDING_INTERVIEW: 'Pelo que me contou na entrevista', MATCH: 'Pelo seu perfil na Shakers' },
|
|
285
|
+
openProjects: (n) => (n === 1 ? 'Neste momento há 1 projeto aberto que procura este perfil.' : `Neste momento há ${n} projetos abertos que procuram este perfil.`),
|
|
286
|
+
rolesOutro: 'O seu perfil principal é o que as empresas veem primeiro e o que usamos para lhe recomendar projetos. Pode adicionar mais e alterá-lo quando quiser.',
|
|
287
|
+
mainRoleQuestion: 'Qual é o seu perfil principal?',
|
|
288
|
+
mainRoleOther: 'Outro perfil do catálogo',
|
|
289
|
+
hourlyRateLabel: 'Tarifa por hora',
|
|
290
|
+
annualRateLabel: 'Tarifa anual',
|
|
291
|
+
perHour: '€/h',
|
|
292
|
+
perYear: '€/ano',
|
|
293
|
+
ratesSaved: (rates) => `${rates.join(' · ')}. É uma estimativa: pode alterá-la na web.`,
|
|
294
|
+
saved: { workSituation: 'Situação profissional guardada.', availability: 'Disponibilidade guardada.', languages: 'Idiomas guardados.', phone: 'Telefone guardado.' },
|
|
295
|
+
yes: 'Sim',
|
|
296
|
+
no: 'Não',
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
function toSignupLanguage(code) {
|
|
301
|
+
const base = String(code || '').toLowerCase().split(/[-_]/)[0];
|
|
302
|
+
return SIGNUP_LANGUAGES.includes(base) ? base : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function signupCopy(lang) {
|
|
306
|
+
return COPY[toSignupLanguage(lang) || 'en'];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function legalCopy(lang) {
|
|
310
|
+
return LEGAL[toSignupLanguage(lang) || 'en'];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// The profile draft the talent confirms before the account exists: one line per group, empty parts left out.
|
|
314
|
+
function renderDraft(lang, d = {}) {
|
|
315
|
+
const c = signupCopy(lang);
|
|
316
|
+
const has = (v) => v !== null && v !== undefined && String(v).trim() !== '';
|
|
317
|
+
const lines = [
|
|
318
|
+
joinParts([d.name, d.role, d.city]),
|
|
319
|
+
joinParts([has(d.yearsOfExperience) ? c.years(d.yearsOfExperience) : null, d.stack]),
|
|
320
|
+
joinParts([d.languages, d.workMode, has(d.monthlyHours) ? c.hours(d.monthlyHours) : null]),
|
|
321
|
+
has(d.hourlyRate) || has(d.annualRate) ? c.rates(has(d.hourlyRate) ? d.hourlyRate : null, has(d.annualRate) ? d.annualRate : null) : '',
|
|
322
|
+
].filter(Boolean);
|
|
323
|
+
return [c.draftTitle, ...lines].join('\n');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// One role of the main-role step: name, why it fits (source + one fact) and, when known, how many open projects want it.
|
|
327
|
+
function renderRole(lang, { name, source, evidence, openProjects, recommended }) {
|
|
328
|
+
const c = signupCopy(lang);
|
|
329
|
+
const reason = c.roleReason[source] || null;
|
|
330
|
+
const why = reason && evidence ? `${reason}: ${evidence}` : reason || evidence || null;
|
|
331
|
+
return [
|
|
332
|
+
recommended ? c.roleRecommended(name) : c.roleName(name),
|
|
333
|
+
why,
|
|
334
|
+
typeof openProjects === 'number' && openProjects > 0 ? c.openProjects(openProjects) : null,
|
|
335
|
+
].filter(Boolean).join('\n');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = { SIGNUP_LANGUAGES, LEGAL, signupCopy, legalCopy, toSignupLanguage, renderDraft, renderRole, amount };
|