shakers-cli 0.0.0-stage → 0.14.1

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.
Files changed (237) hide show
  1. package/LICENSE +59 -0
  2. package/README.md +1233 -2
  3. package/bin/add-agent.js +88 -0
  4. package/bin/add-project.js +88 -0
  5. package/bin/add-role.js +61 -0
  6. package/bin/add-skill.js +88 -0
  7. package/bin/ai-usage.js +99 -0
  8. package/bin/alma.js +99 -0
  9. package/bin/applications.js +69 -0
  10. package/bin/availability.js +70 -0
  11. package/bin/certifications.js +69 -0
  12. package/bin/certify.js +73 -0
  13. package/bin/change-role.js +61 -0
  14. package/bin/config.js +124 -0
  15. package/bin/experiences.js +69 -0
  16. package/bin/find-projects.js +89 -0
  17. package/bin/invitations.js +69 -0
  18. package/bin/lang.js +70 -0
  19. package/bin/login.js +216 -0
  20. package/bin/mcp.js +124 -0
  21. package/bin/mcpb.js +5 -0
  22. package/bin/me.js +69 -0
  23. package/bin/onboarding.js +134 -0
  24. package/bin/portfolios.js +69 -0
  25. package/bin/rate.js +70 -0
  26. package/bin/register.js +337 -0
  27. package/bin/report-html.js +134 -0
  28. package/bin/save.js +77 -0
  29. package/bin/shakers.js +363 -0
  30. package/bin/share.js +72 -0
  31. package/bin/show-project.js +72 -0
  32. package/bin/socials.js +70 -0
  33. package/bin/superadmin.js +351 -0
  34. package/bin/update-check-worker.js +70 -0
  35. package/bin/update.js +111 -0
  36. package/package.json +40 -4
  37. package/src/agent-category.js +13 -0
  38. package/src/agent-evaluation.js +177 -0
  39. package/src/agent-org-chart.js +355 -0
  40. package/src/agent-synthesis.js +132 -0
  41. package/src/agent-usage.js +118 -0
  42. package/src/agents-client.js +165 -0
  43. package/src/ai-fluency-cell.js +72 -0
  44. package/src/ai-profile-client.js +45 -0
  45. package/src/ai-profile-preview.js +154 -0
  46. package/src/ai.js +66 -0
  47. package/src/alma-client.js +123 -0
  48. package/src/alma-flow.js +32 -0
  49. package/src/alma-repo-context.js +47 -0
  50. package/src/alma-shell.js +161 -0
  51. package/src/ansi.js +62 -0
  52. package/src/applications-client.js +79 -0
  53. package/src/applications-flow.js +58 -0
  54. package/src/auth-client.js +144 -0
  55. package/src/auth-device-client.js +118 -0
  56. package/src/auth-method-picker.js +44 -0
  57. package/src/auth-methods.js +35 -0
  58. package/src/auth-session-store.js +215 -0
  59. package/src/authorship-mismatch.js +62 -0
  60. package/src/authorship.js +204 -0
  61. package/src/automations-detector.js +170 -0
  62. package/src/availability-client.js +93 -0
  63. package/src/availability-flow.js +124 -0
  64. package/src/backend-request.js +191 -0
  65. package/src/blind-approval-extract.js +48 -0
  66. package/src/brand-ansi.js +14 -0
  67. package/src/brand-svg.js +9 -0
  68. package/src/browser-tools-detector.js +21 -0
  69. package/src/build-next-level.js +56 -0
  70. package/src/certifications-client.js +89 -0
  71. package/src/certifications-flow.js +75 -0
  72. package/src/certify-dimension-client.js +163 -0
  73. package/src/certify-dimension-flow.js +135 -0
  74. package/src/certify-disclaimer.js +51 -0
  75. package/src/certify-remediation-prompt.js +23 -0
  76. package/src/cli-args.js +83 -0
  77. package/src/command-audit.js +105 -0
  78. package/src/config-dir.js +120 -0
  79. package/src/config.js +877 -0
  80. package/src/consent-flow.js +115 -0
  81. package/src/consent-skip.js +27 -0
  82. package/src/cv-file.js +62 -0
  83. package/src/cv-text.js +92 -0
  84. package/src/decisions-extract.js +43 -0
  85. package/src/detect-repos.js +145 -0
  86. package/src/detected-tools.js +15 -0
  87. package/src/detection-evidence.js +105 -0
  88. package/src/detection-noise.js +132 -0
  89. package/src/detectors.js +221 -0
  90. package/src/device-auth-store.js +100 -0
  91. package/src/device-login.js +120 -0
  92. package/src/email-verification.js +152 -0
  93. package/src/env-paths.js +30 -0
  94. package/src/find-projects-cache.js +89 -0
  95. package/src/find-projects-client.js +148 -0
  96. package/src/find-projects-flow.js +164 -0
  97. package/src/git-activity.js +124 -0
  98. package/src/git-log-prefetch.js +53 -0
  99. package/src/graph-certs.js +58 -0
  100. package/src/graph-generator.js +230 -0
  101. package/src/graph-scan.js +239 -0
  102. package/src/i18n.js +3341 -0
  103. package/src/inter-fonts.js +6 -0
  104. package/src/interactive-select.js +183 -0
  105. package/src/interview-client.js +226 -0
  106. package/src/interview-transcript.js +56 -0
  107. package/src/inventory-client.js +90 -0
  108. package/src/invitations-client.js +66 -0
  109. package/src/invitations-flow.js +50 -0
  110. package/src/lang-client.js +147 -0
  111. package/src/lang-codes.js +14 -0
  112. package/src/lang-flow.js +85 -0
  113. package/src/legal-notice.js +53 -0
  114. package/src/locale.js +72 -0
  115. package/src/markdown-ansi.js +50 -0
  116. package/src/maturity.js +111 -0
  117. package/src/mcp-ai-usage-result-tool.js +111 -0
  118. package/src/mcp-ai-usage-tool.js +401 -0
  119. package/src/mcp-alma-tools.js +95 -0
  120. package/src/mcp-applications-tools.js +44 -0
  121. package/src/mcp-auth-form.js +480 -0
  122. package/src/mcp-availability-tools.js +45 -0
  123. package/src/mcp-certifications-tools.js +44 -0
  124. package/src/mcp-certify-dimension-tools.js +66 -0
  125. package/src/mcp-choice.js +95 -0
  126. package/src/mcp-detector.js +223 -0
  127. package/src/mcp-device-login-tools.js +177 -0
  128. package/src/mcp-find-projects-tools.js +69 -0
  129. package/src/mcp-install.js +275 -0
  130. package/src/mcp-instructions.js +27 -0
  131. package/src/mcp-invitations-tools.js +44 -0
  132. package/src/mcp-lang-tools.js +41 -0
  133. package/src/mcp-onboarding-tools.js +379 -0
  134. package/src/mcp-portfolios-tools.js +50 -0
  135. package/src/mcp-profile-tools.js +48 -0
  136. package/src/mcp-prompts.js +73 -0
  137. package/src/mcp-rate-tools.js +44 -0
  138. package/src/mcp-register-tools.js +441 -0
  139. package/src/mcp-report-tools.js +105 -0
  140. package/src/mcp-role-tools.js +372 -0
  141. package/src/mcp-saved-positions-tools.js +59 -0
  142. package/src/mcp-server.js +290 -0
  143. package/src/mcp-show-project-tools.js +52 -0
  144. package/src/mcp-signup-client.js +118 -0
  145. package/src/mcp-signup-tools.js +1171 -0
  146. package/src/mcp-socials-tools.js +41 -0
  147. package/src/memory-structure-detector.js +108 -0
  148. package/src/model-call-record.js +198 -0
  149. package/src/onboarding-availability.js +26 -0
  150. package/src/onboarding-client.js +374 -0
  151. package/src/onboarding-flow.js +1162 -0
  152. package/src/onboarding-funnel.js +272 -0
  153. package/src/onboarding-livekit-client.js +102 -0
  154. package/src/onboarding-status-client.js +57 -0
  155. package/src/open-file.js +32 -0
  156. package/src/osc-link.js +16 -0
  157. package/src/portfolio-client.js +164 -0
  158. package/src/portfolio-git-info.js +61 -0
  159. package/src/portfolios-client.js +85 -0
  160. package/src/portfolios-flow.js +79 -0
  161. package/src/profile-client.js +133 -0
  162. package/src/profile-flow.js +115 -0
  163. package/src/profile-switch.js +46 -0
  164. package/src/prompt-select.js +66 -0
  165. package/src/rate-client.js +81 -0
  166. package/src/rate-flow.js +137 -0
  167. package/src/register-context.js +145 -0
  168. package/src/render-ai-fluency.js +266 -0
  169. package/src/render-certification.js +177 -0
  170. package/src/render-html.js +140 -0
  171. package/src/render-report-text.js +27 -0
  172. package/src/render-sheet.js +261 -0
  173. package/src/render-terminal.js +109 -0
  174. package/src/render-usage-report-text.js +155 -0
  175. package/src/repo-context.js +215 -0
  176. package/src/report-gating.js +49 -0
  177. package/src/report-store.js +226 -0
  178. package/src/report-theme.js +177 -0
  179. package/src/report-view-client.js +50 -0
  180. package/src/reveal-output.js +29 -0
  181. package/src/roadmap-content.js +453 -0
  182. package/src/roadmap-personalization.js +132 -0
  183. package/src/roadmap-prompt.js +154 -0
  184. package/src/roles-client.js +112 -0
  185. package/src/roles-flow.js +192 -0
  186. package/src/sanitize-network-text.js +36 -0
  187. package/src/saved-positions-client.js +71 -0
  188. package/src/saved-positions-flow.js +50 -0
  189. package/src/scan-exclusions.js +50 -0
  190. package/src/scanner.js +624 -0
  191. package/src/scope-signals.js +148 -0
  192. package/src/secret-prompt.js +72 -0
  193. package/src/session-gate.js +30 -0
  194. package/src/session-normalizers.js +174 -0
  195. package/src/session-refresh.js +51 -0
  196. package/src/session-scan.js +475 -0
  197. package/src/session-signals.js +36 -0
  198. package/src/shakers-wordmark.js +17 -0
  199. package/src/share-card.js +362 -0
  200. package/src/share-certification.js +111 -0
  201. package/src/share-consent-state.js +157 -0
  202. package/src/share-payload.js +300 -0
  203. package/src/share.js +185 -0
  204. package/src/show-project-client.js +58 -0
  205. package/src/show-project-flow.js +119 -0
  206. package/src/signup-client.js +103 -0
  207. package/src/signup-copy.js +450 -0
  208. package/src/signup-language.js +21 -0
  209. package/src/skills-client.js +102 -0
  210. package/src/socials-client.js +91 -0
  211. package/src/socials-flow.js +83 -0
  212. package/src/specificity-extract.js +41 -0
  213. package/src/start-add-agents.js +306 -0
  214. package/src/start-add-portfolio.js +388 -0
  215. package/src/start-add-skills.js +260 -0
  216. package/src/stdin-ask.js +129 -0
  217. package/src/steering-extract.js +43 -0
  218. package/src/strip-internal-comments.js +45 -0
  219. package/src/tech-detector.js +295 -0
  220. package/src/templates/report-sheet.html +572 -0
  221. package/src/terminal-agents.js +207 -0
  222. package/src/terminal-format.js +85 -0
  223. package/src/terminal-progress.js +106 -0
  224. package/src/terminal-sections.js +219 -0
  225. package/src/tier-analysis.js +126 -0
  226. package/src/tier-engine.js +114 -0
  227. package/src/tier-projection.js +117 -0
  228. package/src/transcript-turns.js +49 -0
  229. package/src/translations-client.js +84 -0
  230. package/src/typewriter.js +154 -0
  231. package/src/update-flow.js +223 -0
  232. package/src/update-notifier.js +113 -0
  233. package/src/usage-model-calls.js +119 -0
  234. package/src/usage-oneshot-commands.js +180 -0
  235. package/src/usage-repo-scope.js +162 -0
  236. package/src/usage-run-steps.js +354 -0
  237. package/src/work-streams.js +79 -0
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ const { postJsonWithTimeout, extractSetCookie } = require('./backend-request');
4
+ const { SESSION_COOKIE_NAME } = require('./auth-client');
5
+ const { toUpperLang } = require('./lang-codes');
6
+
7
+ // Register a talent via the Hub's better-auth email engine, in two steps (owner decision, supersedes ADR-031 for the email path — WEB-login parity): 1.
8
+
9
+ const DEFAULT_TIMEOUT_MS = 30000;
10
+
11
+ function parseJson(raw) {
12
+ try { return raw ? JSON.parse(raw) : null; } catch { return null; }
13
+ }
14
+
15
+ function messageFrom(raw) {
16
+ const body = parseJson(raw);
17
+ if (!body || typeof body !== 'object') return null;
18
+ if (typeof body.message === 'string' && body.message) return body.message;
19
+ if (Array.isArray(body.message) && body.message.length) return body.message.join('; ');
20
+ return null;
21
+ }
22
+
23
+ function buildRegistrationContext({ preferredLanguage, newsletterConsent, freelanceType, freelanceIntent } = {}) {
24
+ const context = {};
25
+ if (typeof preferredLanguage === 'string' && preferredLanguage) context.preferredLanguage = toUpperLang(preferredLanguage);
26
+ context.newsletterConsent = newsletterConsent === true;
27
+ if (typeof freelanceType === 'string' && freelanceType) context.freelanceType = freelanceType;
28
+ if (typeof freelanceIntent === 'string' && freelanceIntent) context.freelanceIntent = freelanceIntent;
29
+ return context;
30
+ }
31
+
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
+ let complete;
42
+ try {
43
+ complete = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, Object.keys(headers).length ? headers : null);
44
+ } catch (e) {
45
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
46
+ }
47
+ if (complete.status < 200 || complete.status >= 300) {
48
+ return { ok: false, reason: `complete-registration-http-${complete.status}`, status: complete.status, code: codeFrom(complete.raw), message: messageFrom(complete.raw) };
49
+ }
50
+ return { ok: true };
51
+ }
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
+
72
+ async function requestSignUp(
73
+ { name, lastName, email, password, preferredLanguage, newsletterConsent, freelanceType, freelanceIntent, claimCode = null } = {},
74
+ { signUpEndpoint, completeRegistrationEndpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
75
+ ) {
76
+ if (!signUpEndpoint) return { ok: false, reason: 'no-endpoint' };
77
+ if (!name || !lastName || !email || !password) return { ok: false, reason: 'missing-fields' };
78
+
79
+ let signUp;
80
+ try {
81
+ signUp = await postJsonWithTimeout(signUpEndpoint, { email, password, name, lastName }, timeoutMs);
82
+ } catch (e) {
83
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
84
+ }
85
+
86
+ // A duplicate email is a normal outcome the caller handles by switching to login.
87
+ if (signUp.status === 409 || signUp.status === 422) return { ok: true, accountExists: true };
88
+ if (signUp.status < 200 || signUp.status >= 300) {
89
+ return { ok: false, reason: `http-${signUp.status}`, status: signUp.status, message: messageFrom(signUp.raw) };
90
+ }
91
+
92
+ const cookie = extractSetCookie(signUp.headers, SESSION_COOKIE_NAME);
93
+ if (!cookie) return { ok: false, reason: 'bad-response' };
94
+
95
+ if (!completeRegistrationEndpoint) return { ok: false, reason: 'no-endpoint' };
96
+
97
+ const context = buildRegistrationContext({ preferredLanguage, newsletterConsent, freelanceType, freelanceIntent });
98
+ const complete = await requestCompleteRegistration(context, { endpoint: completeRegistrationEndpoint, cookie, claimCode, timeoutMs });
99
+ if (!complete.ok) return complete;
100
+ return claimCode ? { ok: true, accountExists: false, claimed: complete.claimed === true } : { ok: true, accountExists: false };
101
+ }
102
+
103
+ module.exports = { requestSignUp, requestCompleteRegistration, buildRegistrationContext };
@@ -0,0 +1,450 @@
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. The CLI onboarding reads the es/en ones too.
8
+ const LEGAL = {
9
+ es: {
10
+ signupNotice: (sources) => `Antes de crear tu cuenta: guardamos los datos que me des (${listOf('es', [...named(sources, COPY.es.importSources), '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: (sources) => `Before we create your account: we store the data you give me (${listOf('en', [...named(sources, COPY.en.importSources), '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: (sources) => `Prima di creare il tuo account: conserviamo i dati che mi dai (${listOf('it', [...named(sources, COPY.it.importSources), '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 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: (sources) => `Antes de criar a sua conta: guardamos os dados que me der (${listOf('pt', [...named(sources, COPY.pt.importSources), '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 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
+ // "a, b and c" in the talent's language.
42
+ function listOf(lang, items) {
43
+ return new Intl.ListFormat(LOCALES[lang], { type: 'conjunction' }).format(items);
44
+ }
45
+
46
+ // The names of only the sources the talent actually gave (cv, linkedin), from a per-language table.
47
+ function named(sources, names) {
48
+ return (sources || []).map((s) => names[s]).filter(Boolean);
49
+ }
50
+
51
+ function joinParts(parts) {
52
+ return parts.filter((p) => p !== null && p !== undefined && String(p).trim() !== '').join(' · ');
53
+ }
54
+
55
+ function countryName(lang, code) {
56
+ if (typeof code !== 'string' || !/^[A-Za-z]{2}$/.test(code.trim())) return null;
57
+ const upper = code.trim().toUpperCase();
58
+ try {
59
+ return new Intl.DisplayNames([LOCALES[toSignupLanguage(lang) || 'en']], { type: 'region' }).of(upper) || upper;
60
+ } catch {
61
+ return upper;
62
+ }
63
+ }
64
+
65
+ function amount(lang, value) {
66
+ return new Intl.NumberFormat(LOCALES[lang], { maximumFractionDigits: 0 }).format(Number(value));
67
+ }
68
+
69
+ const COPY = {
70
+ es: {
71
+ 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.`,
72
+ welcomeQuestion: '¿Empezamos? Pásame tu CV, o si lo tienes en este equipo, te lo busco yo. Con él te preparo un borrador para que solo tengas que confirmarlo.',
73
+ cvSearch: 'Búscalo en mi equipo',
74
+ cvAttach: 'Te lo paso',
75
+ cvAttachAsk: 'Perfecto, adjúntalo aquí o dime dónde está en tu equipo.',
76
+ cvNotFound: 'No he encontrado tu CV en este equipo. ¿Me lo adjuntas aquí?',
77
+ linkedinAsk: '¿Me pasas tu perfil de LinkedIn?',
78
+ linkedinOpening: 'Te abro tu perfil de LinkedIn en el navegador para que copies la dirección.',
79
+ linkedinOpened: 'Te he abierto tu perfil de LinkedIn; copia la dirección de la barra y pégamela aquí.',
80
+ 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.`,
81
+ 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.',
82
+ emailWorkHint: 'Si es el de tu trabajo actual, quizá prefieras uno personal para no perder el acceso si cambias de empresa.',
83
+ emailYes: 'Sí, ese',
84
+ emailOther: 'Usar otro',
85
+ draftTitle: 'Esto es lo que he preparado para tu perfil:',
86
+ years: (n) => `${n} años de experiencia`,
87
+ hours: (h) => `${h} h/mes`,
88
+ rates: (hourly, annual, estimated = true) => (estimated ? `Tarifa estimada: ${joinParts([hourly != null ? `${amount('es', hourly)} €/h` : null, annual != null ? `${amount('es', annual)} €/año` : null])} (estimación mía, ajústala)` : `Tarifa: ${joinParts([hourly != null ? `${amount('es', hourly)} €/h` : null, annual != null ? `${amount('es', annual)} €/año` : null])}`),
89
+ draftQuestion: '¿Está bien o cambio algo?',
90
+ draftOk: 'Todo correcto',
91
+ draftChange: 'Quiero cambiar algo',
92
+ windowNotice: 'Cuando me confirmes, solo faltará 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.',
93
+ windowNoticeSignIn: 'Cuando me confirmes, solo faltará entrar en la cuenta que ya tienes en Shakers con este email para guardar todo esto.\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.\n\n👉 Mira tu navegador. Cuando termines, vuelve aquí y seguimos.',
94
+ windowLink: (link) => `Si no se te ha abierto la ventana, entra desde aquí: ${link}`,
95
+ existingTitle: 'He visto que ya tienes cuenta. ¿Actualizo tu perfil con esto?',
96
+ existingRate: (from, to) => `Tarifa por hora: ${from} → ${to}`,
97
+ existingAnnual: (from, to) => `Tarifa anual: ${from} → ${to}`,
98
+ existingHours: (from, to) => `Disponibilidad: ${from} → ${to}`,
99
+ existingEmpty: 'sin indicar',
100
+ existingUpdateQuestion: '¿Actualizo tu perfil con esto?',
101
+ existingStays: (label, kept, requested) => (kept ? `${label}: se queda en ${kept}${kept === requested ? '' : `, no ${requested}`}` : `${label}: sin cambios`),
102
+ existingStaysNote: 'Lo que ya tenías relleno no lo cambio desde aquí: si quieres otros valores, cámbialos en la web.',
103
+ existingLists: (sources) => `Añado lo nuevo de ${listOf('es', named(sources, COPY.es.importSources))} (experiencias, proyectos, formación) y relleno lo que tengas vacío, sin borrar nada.`,
104
+ existingYes: 'Sí, actualízalo',
105
+ existingNo: 'No, déjalo como está',
106
+ existingKept: 'Perfecto, dejo tu perfil como está.',
107
+ keptLabels: { workSituation: 'Situación laboral', availability: 'Disponibilidad', languages: 'Idiomas', phone: 'Teléfono' },
108
+ situationLabels: { FREELANCE: 'Freelance', EMPLOYED: 'Empleado/a', BETWEEN_JOBS: 'Entre proyectos', STUDYING: 'Estudiando', OTHER: 'Otro' },
109
+ participationLabels: { FULL_TIME: 'jornada completa', PART_TIME: 'media jornada' },
110
+ existingUnchanged: (items) => `Estos datos de tu perfil no los he cambiado, se quedan como estaban: ${items.join(' · ')}. Si quieres otros valores, cámbialos en la web.`,
111
+ 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. Miro el uso de IA de todo este equipo, que doy por tuyo. Te ayuda a destacar en proyectos de IA.',
112
+ aiUsageYes: 'Sí, analízalo',
113
+ aiUsageSkip: 'Saltar este paso',
114
+ 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'.",
115
+ consent: {
116
+ yes: 'Sí, adelante',
117
+ no: 'No',
118
+ cvSearch: 'Shakers buscará en este ordenador archivos que puedan ser tu CV: mira los nombres de los archivos PDF y Word de tus carpetas Escritorio, Documentos, Descargas e iCloud Drive, y lee tu zona horaria y el nombre, el email y el usuario de GitHub que guardan tus herramientas de programación. No abre esos archivos y no envía nada. ¿Continuamos?',
119
+ cvRead: (file) => `Shakers leerá el archivo ${file} para preparar el borrador de tu perfil. Todavía no se envía nada. ¿Continuamos?`,
120
+ cvSend: (file) => `Shakers enviará el archivo ${file} a tu cuenta de Shakers para completar tu perfil. ¿Continuamos?`,
121
+ project: (p) => `Shakers añadirá el proyecto de la carpeta ${p.folder} a tu portfolio y enviará ${listOf('es', [`el nombre "${p.name}"`, p.url && `la dirección web ${p.url}`, p.startDate && p.endDate ? `las fechas del ${p.startDate} al ${p.endDate}` : (p.startDate || p.endDate) && `la fecha ${p.startDate || p.endDate}`, p.client && `el cliente ${p.client}`, p.description && 'la descripción que acordaste'].filter(Boolean))}. ¿Continuamos?`,
122
+ },
123
+ importSources: { linkedin: 'tu LinkedIn', cv: 'tu CV', github: 'tu GitHub', web: 'tu web' },
124
+ importFailed: (names) => `No he podido importar ${names.join(' y ')} ahora mismo. El resto de tu perfil está guardado; lo que falte lo puedes añadir en la web.`,
125
+ interviewWhereQuestion: '¿Hacemos la entrevista de onboarding aquí ahora o luego en la web?',
126
+ interviewHere: 'Aquí ahora',
127
+ interviewLater: 'Luego en la web',
128
+ answerModeQuestion: '¿Te propongo yo las respuestas con lo que sé de ti, o las contestas tú?',
129
+ answerModeDrafted: 'Propónmelas tú',
130
+ answerModeOwn: 'Las contesto yo',
131
+ answerModeFull: 'Hazla entera',
132
+ interviewOnWeb: 'La entrevista de onboarding la puedes hacer en la web: al terminar te abro Shakers con tu sesión iniciada.',
133
+ livekitUnavailable: 'La entrevista no puede arrancar en este equipo. Puedes hacerla en la web: te abro Shakers con tu sesión iniciada.',
134
+ rolesIntro: 'Con todo lo que me has contado, encajas en estos perfiles:',
135
+ roleRecommended: (name) => `**${name}** (recomendado)`,
136
+ roleName: (name) => `**${name}**`,
137
+ roleReason: { IMPORT: 'Por tu CV y tu LinkedIn', ONBOARDING_INTERVIEW: 'Por lo que me contaste en la entrevista', MATCH: 'Por tu perfil en Shakers' },
138
+ openProjects: (n) => (n === 1 ? 'Ahora mismo hay 1 proyecto abierto que busca este perfil.' : `Ahora mismo hay ${n} proyectos abiertos que buscan este perfil.`),
139
+ 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.',
140
+ mainRoleQuestion: '¿Cuál es tu perfil principal?',
141
+ mainRoleOther: 'Otro rol del catálogo',
142
+ mainRoleAsk: '¿A qué te dedicas? Dime tu rol con tus palabras y lo busco en el catálogo.',
143
+ mainRoleNotListed: 'Otro rol',
144
+ catalogueReason: { role: (words) => `Encaja con tu rol (${words})`, stack: (words) => `Encaja con tu stack (${words})` },
145
+ hourlyRateLabel: 'Tarifa por hora',
146
+ annualRateLabel: 'Tarifa anual',
147
+ perHour: '€/h',
148
+ perYear: '€/año',
149
+ ratesSaved: (rates, estimated = true) => `${rates.join(' · ')}. ${estimated ? 'Es una estimación: la puedes cambiar en la web.' : 'La puedes cambiar en la web.'}`,
150
+ saved: { workSituation: 'Situación laboral guardada.', availability: 'Disponibilidad guardada.', languages: 'Idiomas guardados.', phone: 'Teléfono guardado.' },
151
+ yes: 'Sí',
152
+ no: 'No',
153
+ },
154
+ en: {
155
+ 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.`,
156
+ welcomeQuestion: "Shall we start? Send me your CV, or if it is on this computer, I'll look for it. With it I'll prepare a draft so you only have to confirm it.",
157
+ cvSearch: 'Look for it on my computer',
158
+ cvAttach: "I'll send it",
159
+ cvAttachAsk: 'Great, attach it here or tell me where it is on your computer.',
160
+ cvNotFound: "I couldn't find your CV on this computer. Could you attach it here?",
161
+ linkedinAsk: 'Could you send me your LinkedIn profile?',
162
+ linkedinOpening: 'I am opening your LinkedIn profile in your browser so you can copy its address.',
163
+ linkedinOpened: 'I have opened your LinkedIn profile; copy the address from the address bar and paste it here.',
164
+ 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.`,
165
+ 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.',
166
+ emailWorkHint: 'If it is your current work email, you may prefer a personal one so you do not lose access if you change companies.',
167
+ emailYes: 'Yes, that one',
168
+ emailOther: 'Use another one',
169
+ draftTitle: 'Here is what I have prepared for your profile:',
170
+ years: (n) => `${n} years of experience`,
171
+ hours: (h) => `${h} h/month`,
172
+ rates: (hourly, annual, estimated = true) => (estimated ? `Estimated rate: ${joinParts([hourly != null ? `${amount('en', hourly)} €/h` : null, annual != null ? `${amount('en', annual)} €/year` : null])} (my estimate, adjust it)` : `Rate: ${joinParts([hourly != null ? `${amount('en', hourly)} €/h` : null, annual != null ? `${amount('en', annual)} €/year` : null])}`),
173
+ draftQuestion: 'Does it look right, or shall I change something?',
174
+ draftOk: 'All correct',
175
+ draftChange: 'I want to change something',
176
+ windowNotice: "Once you confirm, all that's left is to create your account to save all this and so you can get into Shakers whenever you like.\n\nI'll open 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.",
177
+ windowNoticeSignIn: "Once you confirm, all that's left is to sign in to the account you already have on Shakers with this email to save all this.\n\nI'll open 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.\n\n👉 Check your browser. When you're done, come back here and we'll carry on.",
178
+ windowLink: (link) => `If the window did not open, use this link: ${link}`,
179
+ existingTitle: 'I see you already have an account. Shall I update your profile with this?',
180
+ existingRate: (from, to) => `Hourly rate: ${from} → ${to}`,
181
+ existingAnnual: (from, to) => `Annual rate: ${from} → ${to}`,
182
+ existingHours: (from, to) => `Availability: ${from} → ${to}`,
183
+ existingEmpty: 'not set',
184
+ existingUpdateQuestion: 'Shall I update your profile with this?',
185
+ existingStays: (label, kept, requested) => (kept ? `${label}: stays at ${kept}${kept === requested ? '' : `, not ${requested}`}` : `${label}: no change`),
186
+ existingStaysNote: 'I do not change from here what you had already filled in: if you want other values, change them on the web.',
187
+ existingLists: (sources) => `I add what is new from ${listOf('en', named(sources, COPY.en.importSources))} (experience, projects, education) and fill in what is empty, without deleting anything.`,
188
+ existingYes: 'Yes, update it',
189
+ existingNo: 'No, leave it as it is',
190
+ existingKept: 'Sure, I am leaving your profile as it is.',
191
+ keptLabels: { workSituation: 'Work situation', availability: 'Availability', languages: 'Languages', phone: 'Phone' },
192
+ situationLabels: { FREELANCE: 'Freelance', EMPLOYED: 'Employed', BETWEEN_JOBS: 'Between jobs', STUDYING: 'Studying', OTHER: 'Other' },
193
+ participationLabels: { FULL_TIME: 'full-time', PART_TIME: 'part-time' },
194
+ existingUnchanged: (items) => `I did not change these details of your profile; they stay as they were: ${items.join(' · ')}. If you want other values, change them on the web.`,
195
+ 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. I look at the AI usage across this whole machine, which I take to be yours. It helps you stand out for AI projects.',
196
+ aiUsageYes: 'Yes, analyse it',
197
+ aiUsageSkip: 'Skip this step',
198
+ 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'.",
199
+ consent: {
200
+ yes: 'Yes, go ahead',
201
+ no: 'No',
202
+ cvSearch: 'Shakers will look on this computer for files that may be your CV: it lists the names of the PDF and Word files in your Desktop, Documents, Downloads and iCloud Drive folders, and reads your time zone and the name, email and GitHub user saved by your coding tools. It does not open those files and nothing is sent. Continue?',
203
+ cvRead: (file) => `Shakers will read the file ${file} to prepare your profile draft. Nothing is sent yet. Continue?`,
204
+ cvSend: (file) => `Shakers will send the file ${file} to your Shakers account to fill in your profile. Continue?`,
205
+ project: (p) => `Shakers will add the project in the folder ${p.folder} to your portfolio and send ${listOf('en', [`the name "${p.name}"`, p.url && `the web address ${p.url}`, p.startDate && p.endDate ? `the dates ${p.startDate} to ${p.endDate}` : (p.startDate || p.endDate) && `the date ${p.startDate || p.endDate}`, p.client && `the client ${p.client}`, p.description && 'the description you agreed on'].filter(Boolean))}. Continue?`,
206
+ },
207
+ importSources: { linkedin: 'your LinkedIn', cv: 'your CV', github: 'your GitHub', web: 'your website' },
208
+ importFailed: (names) => `I couldn't import ${names.join(' and ')} right now. The rest of your profile is saved; you can add what is missing on the web.`,
209
+ interviewWhereQuestion: 'Shall we do the onboarding interview here now or later on the web?',
210
+ interviewHere: 'Here now',
211
+ interviewLater: 'Later on the web',
212
+ answerModeQuestion: 'Shall I draft your answers from what I know about you, or will you answer yourself?',
213
+ answerModeDrafted: 'Draft them for me',
214
+ answerModeOwn: "I'll answer myself",
215
+ answerModeFull: 'Do it all for me',
216
+ interviewOnWeb: 'You can do the onboarding interview on the web: when we finish I will open Shakers for you, already signed in.',
217
+ livekitUnavailable: 'The interview cannot start on this machine. You can do it on the web: I will open Shakers with you signed in.',
218
+ rolesIntro: 'With everything you have told me, you fit these profiles:',
219
+ roleRecommended: (name) => `**${name}** (recommended)`,
220
+ roleName: (name) => `**${name}**`,
221
+ roleReason: { IMPORT: 'From your CV and LinkedIn', ONBOARDING_INTERVIEW: 'From what you told me in the interview', MATCH: 'From your Shakers profile' },
222
+ 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.`),
223
+ 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.',
224
+ mainRoleQuestion: 'Which is your main profile?',
225
+ mainRoleOther: 'Another role from the catalogue',
226
+ mainRoleAsk: 'What do you do? Tell me your role in your own words and I will look it up in the catalogue.',
227
+ mainRoleNotListed: 'Another role',
228
+ catalogueReason: { role: (words) => `Matches your role (${words})`, stack: (words) => `Matches your stack (${words})` },
229
+ hourlyRateLabel: 'Hourly rate',
230
+ annualRateLabel: 'Annual rate',
231
+ perHour: '€/hour',
232
+ perYear: '€/year',
233
+ ratesSaved: (rates, estimated = true) => `${rates.join(' · ')}. ${estimated ? 'It is an estimate: you can change it on the web.' : 'You can change it on the web.'}`,
234
+ saved: { workSituation: 'Work situation saved.', availability: 'Availability saved.', languages: 'Languages saved.', phone: 'Phone saved.' },
235
+ yes: 'Yes',
236
+ no: 'No',
237
+ },
238
+ it: {
239
+ 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. Lo fai sul sito, quando vuoi.\n\nPuoi modificare tutto in seguito dal sito.`,
240
+ welcomeQuestion: 'Iniziamo? Mandami il tuo CV, oppure, se ce l’hai su questo computer, lo cerco io. Con quello ti preparo una bozza da confermare e basta.',
241
+ cvSearch: 'Cercalo nel mio computer',
242
+ cvAttach: 'Te lo mando',
243
+ cvAttachAsk: 'Perfetto, allegalo qui o dimmi dove si trova nel tuo computer.',
244
+ cvNotFound: 'Non ho trovato il tuo CV su questo computer. Me lo alleghi qui?',
245
+ linkedinAsk: 'Mi mandi il tuo profilo LinkedIn?',
246
+ linkedinOpening: "Ti apro il tuo profilo LinkedIn nel browser così puoi copiarne l'indirizzo.",
247
+ linkedinOpened: "Ti ho aperto il tuo profilo LinkedIn; copia l'indirizzo dalla barra e incollalo qui.",
248
+ 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.`,
249
+ 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.",
250
+ emailWorkHint: "Se è quella del tuo lavoro attuale, forse ne preferisci una personale per non perdere l'accesso se cambi azienda.",
251
+ emailYes: 'Sì, quella',
252
+ emailOther: "Usane un'altra",
253
+ draftTitle: 'Ecco cosa ho preparato per il tuo profilo:',
254
+ years: (n) => `${n} anni di esperienza`,
255
+ hours: (h) => `${h} h/mese`,
256
+ rates: (hourly, annual, estimated = true) => (estimated ? `Tariffa stimata: ${joinParts([hourly != null ? `${amount('it', hourly)} €/h` : null, annual != null ? `${amount('it', annual)} €/anno` : null])} (una mia stima, modificala pure)` : `Tariffa: ${joinParts([hourly != null ? `${amount('it', hourly)} €/h` : null, annual != null ? `${amount('it', annual)} €/anno` : null])}`),
257
+ draftQuestion: 'Va bene o cambio qualcosa?',
258
+ draftOk: 'Tutto corretto',
259
+ draftChange: 'Voglio cambiare qualcosa',
260
+ windowNotice: "Quando mi confermi, mancherà 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.",
261
+ windowNoticeSignIn: "Quando mi confermi, mancherà solo entrare nell'account che hai già su Shakers con questa email per salvare tutto questo.\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.\n\n👉 Guarda il tuo browser. Quando hai finito, torna qui e continuiamo.",
262
+ windowLink: (link) => `Se la finestra non si è aperta, entra da qui: ${link}`,
263
+ existingTitle: 'Ho visto che hai già un account. Aggiorno il tuo profilo con questi dati?',
264
+ existingRate: (from, to) => `Tariffa oraria: ${from} → ${to}`,
265
+ existingAnnual: (from, to) => `Tariffa annuale: ${from} → ${to}`,
266
+ existingHours: (from, to) => `Disponibilità: ${from} → ${to}`,
267
+ existingEmpty: 'non indicata',
268
+ existingUpdateQuestion: 'Aggiorno il tuo profilo con questi dati?',
269
+ existingStays: (label, kept, requested) => (kept ? `${label}: resta ${kept}${kept === requested ? '' : `, non ${requested}`}` : `${label}: nessuna modifica`),
270
+ existingStaysNote: 'Quello che avevi già compilato non lo cambio da qui: se vuoi altri valori, cambiali sul sito.',
271
+ existingLists: (sources) => `Aggiungo le novità ${listOf('it', named(sources, { cv: 'del tuo CV', linkedin: 'del tuo LinkedIn' }))} (esperienze, progetti, formazione) e compilo quello che hai vuoto, senza cancellare nulla.`,
272
+ existingYes: 'Sì, aggiornalo',
273
+ existingNo: 'No, lascialo così',
274
+ existingKept: 'Perfetto, lascio il tuo profilo così com’è.',
275
+ keptLabels: { workSituation: 'Situazione lavorativa', availability: 'Disponibilità', languages: 'Lingue', phone: 'Telefono' },
276
+ situationLabels: { FREELANCE: 'Freelance', EMPLOYED: 'Dipendente', BETWEEN_JOBS: "Tra un lavoro e l'altro", STUDYING: 'Studente', OTHER: 'Altro' },
277
+ participationLabels: { FULL_TIME: 'tempo pieno', PART_TIME: 'part-time' },
278
+ existingUnchanged: (items) => `Questi dati del tuo profilo non li ho cambiati, restano com'erano: ${items.join(' · ')}. Se vuoi altri valori, cambiali sul sito.`,
279
+ 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. Guardo l'uso dell'IA su tutto questo computer, che considero tuo. Ti aiuta a distinguerti nei progetti di IA.",
280
+ aiUsageYes: 'Sì, analizzalo',
281
+ aiUsageSkip: 'Salta questo passaggio',
282
+ 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'.",
283
+ consent: {
284
+ yes: 'Sì, procedi',
285
+ no: 'No',
286
+ cvSearch: "Shakers cercherà su questo computer i file che potrebbero essere il tuo CV: guarda i nomi dei file PDF e Word nelle cartelle Scrivania, Documenti, Download e iCloud Drive, e legge il tuo fuso orario e il nome, l'email e l'utente GitHub salvati dai tuoi strumenti di programmazione. Non apre quei file e non invia nulla. Continuiamo?",
287
+ cvRead: (file) => `Shakers leggerà il file ${file} per preparare la bozza del tuo profilo. Per ora non viene inviato nulla. Continuiamo?`,
288
+ cvSend: (file) => `Shakers invierà il file ${file} al tuo account Shakers per completare il tuo profilo. Continuiamo?`,
289
+ project: (p) => `Shakers aggiungerà il progetto della cartella ${p.folder} al tuo portfolio e invierà ${listOf('it', [`il nome "${p.name}"`, p.url && `l'indirizzo web ${p.url}`, p.startDate && p.endDate ? `le date dal ${p.startDate} al ${p.endDate}` : (p.startDate || p.endDate) && `la data ${p.startDate || p.endDate}`, p.client && `il cliente ${p.client}`, p.description && 'la descrizione che hai concordato'].filter(Boolean))}. Continuiamo?`,
290
+ },
291
+ importSources: { linkedin: 'il tuo LinkedIn', cv: 'il tuo CV', github: 'il tuo GitHub', web: 'il tuo sito' },
292
+ importFailed: (names) => `Non sono riuscito a importare ${names.join(' e ')} in questo momento. Il resto del tuo profilo è salvato; quello che manca puoi aggiungerlo sul sito.`,
293
+ interviewWhereQuestion: 'Facciamo il colloquio di onboarding qui adesso o più tardi sul sito?',
294
+ interviewHere: 'Qui adesso',
295
+ interviewLater: 'Più tardi sul sito',
296
+ answerModeQuestion: 'Ti propongo io le risposte con quello che so di te, o rispondi tu?',
297
+ answerModeDrafted: 'Proponile tu',
298
+ answerModeOwn: 'Rispondo io',
299
+ answerModeFull: 'Fallo tutto tu',
300
+ interviewOnWeb: 'Il colloquio di onboarding puoi farlo sul sito: alla fine ti apro Shakers con la sessione già avviata.',
301
+ livekitUnavailable: 'Il colloquio non può partire su questo computer. Puoi farlo sul sito: ti apro Shakers con la sessione già avviata.',
302
+ rolesIntro: 'Con tutto quello che mi hai raccontato, sei adatto a questi profili:',
303
+ roleRecommended: (name) => `**${name}** (consigliato)`,
304
+ roleName: (name) => `**${name}**`,
305
+ roleReason: { IMPORT: 'Dal tuo CV e da LinkedIn', ONBOARDING_INTERVIEW: 'Da quello che mi hai raccontato nel colloquio', MATCH: 'Dal tuo profilo su Shakers' },
306
+ 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.`),
307
+ 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.',
308
+ mainRoleQuestion: 'Qual è il tuo profilo principale?',
309
+ mainRoleOther: 'Un altro ruolo del catalogo',
310
+ mainRoleAsk: 'Di cosa ti occupi? Dimmi il tuo ruolo con parole tue e lo cerco nel catalogo.',
311
+ mainRoleNotListed: 'Un altro ruolo',
312
+ catalogueReason: { role: (words) => `Corrisponde al tuo ruolo (${words})`, stack: (words) => `Corrisponde al tuo stack (${words})` },
313
+ hourlyRateLabel: 'Tariffa oraria',
314
+ annualRateLabel: 'Tariffa annuale',
315
+ perHour: '€/h',
316
+ perYear: '€/anno',
317
+ ratesSaved: (rates, estimated = true) => `${rates.join(' · ')}. ${estimated ? 'È una stima: puoi cambiarla sul sito.' : 'Puoi cambiarla sul sito.'}`,
318
+ saved: { workSituation: 'Situazione lavorativa salvata.', availability: 'Disponibilità salvata.', languages: 'Lingue salvate.', phone: 'Telefono salvato.' },
319
+ yes: 'Sì',
320
+ no: 'No',
321
+ },
322
+ pt: {
323
+ 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. Faz-se na web, quando quiser.\n\nPode editar tudo depois na web.`,
324
+ welcomeQuestion: 'Começamos? Envie-me o seu CV ou, se o tiver neste computador, procuro-o eu. Com ele preparo um rascunho para que só tenha de o confirmar.',
325
+ cvSearch: 'Procure-o no meu computador',
326
+ cvAttach: 'Envio-o eu',
327
+ cvAttachAsk: 'Perfeito, anexe-o aqui ou diga-me onde está no seu computador.',
328
+ cvNotFound: 'Não encontrei o seu CV neste computador. Pode anexá-lo aqui?',
329
+ linkedinAsk: 'Pode enviar-me o seu perfil do LinkedIn?',
330
+ linkedinOpening: 'Vou abrir o seu perfil do LinkedIn no navegador para que copie o endereço.',
331
+ linkedinOpened: 'Abri o seu perfil do LinkedIn; copie o endereço da barra e cole-o aqui.',
332
+ 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.`,
333
+ 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.',
334
+ emailWorkHint: 'Se for o do seu trabalho atual, talvez prefira um pessoal para não perder o acesso se mudar de empresa.',
335
+ emailYes: 'Sim, esse',
336
+ emailOther: 'Usar outro',
337
+ draftTitle: 'Isto é o que preparei para o seu perfil:',
338
+ years: (n) => `${n} anos de experiência`,
339
+ hours: (h) => `${h} h/mês`,
340
+ rates: (hourly, annual, estimated = true) => (estimated ? `Tarifa estimada: ${joinParts([hourly != null ? `${amount('pt', hourly)} €/h` : null, annual != null ? `${amount('pt', annual)} €/ano` : null])} (estimativa minha, ajuste-a)` : `Tarifa: ${joinParts([hourly != null ? `${amount('pt', hourly)} €/h` : null, annual != null ? `${amount('pt', annual)} €/ano` : null])}`),
341
+ draftQuestion: 'Está bem ou mudo alguma coisa?',
342
+ draftOk: 'Está tudo certo',
343
+ draftChange: 'Quero mudar algo',
344
+ windowNotice: 'Quando me confirmar, só faltará criar a sua conta para guardar tudo isto e poder entrar na Shakers quando quiser.\n\nAbro-lhe uma janela no 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.',
345
+ windowNoticeSignIn: 'Quando me confirmar, só faltará entrar na conta que já tem na Shakers com este email para guardar tudo isto.\n\nAbro-lhe uma janela no 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.\n\n👉 Veja o seu navegador. Quando terminar, volte aqui e continuamos.',
346
+ windowLink: (link) => `Se a janela não abriu, entre por aqui: ${link}`,
347
+ existingTitle: 'Vi que já tem conta. Atualizo o seu perfil com isto?',
348
+ existingRate: (from, to) => `Tarifa por hora: ${from} → ${to}`,
349
+ existingAnnual: (from, to) => `Tarifa anual: ${from} → ${to}`,
350
+ existingHours: (from, to) => `Disponibilidade: ${from} → ${to}`,
351
+ existingEmpty: 'sem indicar',
352
+ existingUpdateQuestion: 'Atualizo o seu perfil com isto?',
353
+ existingStays: (label, kept, requested) => (kept ? `${label}: fica em ${kept}${kept === requested ? '' : `, não ${requested}`}` : `${label}: sem alterações`),
354
+ existingStaysNote: 'O que já tinha preenchido não o altero daqui: se quiser outros valores, altere-os na web.',
355
+ existingLists: (sources) => `Acrescento o que há de novo ${listOf('pt', named(sources, { cv: 'no seu CV', linkedin: 'no seu LinkedIn' }))} (experiências, projetos, formação) e preencho o que tiver vazio, sem apagar nada.`,
356
+ existingYes: 'Sim, atualize',
357
+ existingNo: 'Não, deixe como está',
358
+ existingKept: 'Perfeito, deixo o seu perfil como está.',
359
+ keptLabels: { workSituation: 'Situação profissional', availability: 'Disponibilidade', languages: 'Idiomas', phone: 'Telefone' },
360
+ situationLabels: { FREELANCE: 'Freelancer', EMPLOYED: 'Por conta de outrem', BETWEEN_JOBS: 'Entre trabalhos', STUDYING: 'A estudar', OTHER: 'Outra' },
361
+ participationLabels: { FULL_TIME: 'tempo inteiro', PART_TIME: 'tempo parcial' },
362
+ existingUnchanged: (items) => `Estes dados do seu perfil não os alterei, ficam como estavam: ${items.join(' · ')}. Se quiser outros valores, altere-os na web.`,
363
+ 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. Analiso o uso de IA em todo este computador, que considero seu. Ajuda-o a destacar-se em projetos de IA.',
364
+ aiUsageYes: 'Sim, analise',
365
+ aiUsageSkip: 'Saltar este passo',
366
+ 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'.",
367
+ consent: {
368
+ yes: 'Sim, avançar',
369
+ no: 'Não',
370
+ cvSearch: 'A Shakers vai procurar neste computador ficheiros que possam ser o seu CV: vê os nomes dos ficheiros PDF e Word das pastas Secretária, Documentos, Transferências e iCloud Drive, e lê o seu fuso horário e o nome, o email e o utilizador de GitHub guardados pelas suas ferramentas de programação. Não abre esses ficheiros e não envia nada. Continuamos?',
371
+ cvRead: (file) => `A Shakers vai ler o ficheiro ${file} para preparar o rascunho do seu perfil. Ainda não é enviado nada. Continuamos?`,
372
+ cvSend: (file) => `A Shakers vai enviar o ficheiro ${file} para a sua conta Shakers para completar o seu perfil. Continuamos?`,
373
+ project: (p) => `A Shakers vai adicionar o projeto da pasta ${p.folder} ao seu portfólio e enviar ${listOf('pt', [`o nome "${p.name}"`, p.url && `o endereço web ${p.url}`, p.startDate && p.endDate ? `as datas de ${p.startDate} a ${p.endDate}` : (p.startDate || p.endDate) && `a data ${p.startDate || p.endDate}`, p.client && `o cliente ${p.client}`, p.description && 'a descrição que combinou'].filter(Boolean))}. Continuamos?`,
374
+ },
375
+ importSources: { linkedin: 'o seu LinkedIn', cv: 'o seu CV', github: 'o seu GitHub', web: 'o seu site' },
376
+ importFailed: (names) => `Não consegui importar ${names.join(' e ')} neste momento. O resto do seu perfil está guardado; o que faltar pode acrescentá-lo na web.`,
377
+ interviewWhereQuestion: 'Fazemos a entrevista de onboarding aqui agora ou mais tarde na web?',
378
+ interviewHere: 'Aqui agora',
379
+ interviewLater: 'Mais tarde na web',
380
+ answerModeQuestion: 'Proponho-lhe eu as respostas com o que sei sobre si, ou responde você?',
381
+ answerModeDrafted: 'Proponha-as você',
382
+ answerModeOwn: 'Respondo eu',
383
+ answerModeFull: 'Faça-a toda',
384
+ interviewOnWeb: 'Pode fazer a entrevista de onboarding na web: no fim abro-lhe o Shakers com a sessão iniciada.',
385
+ livekitUnavailable: 'A entrevista não pode começar neste computador. Pode fazê-la na web: abro-lhe a Shakers com a sessão iniciada.',
386
+ rolesIntro: 'Com tudo o que me contou, encaixa nestes perfis:',
387
+ roleRecommended: (name) => `**${name}** (recomendado)`,
388
+ roleName: (name) => `**${name}**`,
389
+ roleReason: { IMPORT: 'Pelo seu CV e pelo LinkedIn', ONBOARDING_INTERVIEW: 'Pelo que me contou na entrevista', MATCH: 'Pelo seu perfil na Shakers' },
390
+ openProjects: (n) => (n === 1 ? 'Neste momento há 1 projeto aberto que procura este perfil.' : `Neste momento há ${n} projetos abertos que procuram este perfil.`),
391
+ 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.',
392
+ mainRoleQuestion: 'Qual é o seu perfil principal?',
393
+ mainRoleOther: 'Outro perfil do catálogo',
394
+ mainRoleAsk: 'A que se dedica? Diga-me o seu perfil por palavras suas e eu procuro-o no catálogo.',
395
+ mainRoleNotListed: 'Outro perfil',
396
+ catalogueReason: { role: (words) => `Corresponde ao seu perfil (${words})`, stack: (words) => `Corresponde ao seu stack (${words})` },
397
+ hourlyRateLabel: 'Tarifa por hora',
398
+ annualRateLabel: 'Tarifa anual',
399
+ perHour: '€/h',
400
+ perYear: '€/ano',
401
+ ratesSaved: (rates, estimated = true) => `${rates.join(' · ')}. ${estimated ? 'É uma estimativa: pode alterá-la na web.' : 'Pode alterá-la na web.'}`,
402
+ saved: { workSituation: 'Situação profissional guardada.', availability: 'Disponibilidade guardada.', languages: 'Idiomas guardados.', phone: 'Telefone guardado.' },
403
+ yes: 'Sim',
404
+ no: 'Não',
405
+ },
406
+ };
407
+
408
+ function toSignupLanguage(code) {
409
+ const base = String(code || '').toLowerCase().split(/[-_]/)[0];
410
+ return SIGNUP_LANGUAGES.includes(base) ? base : null;
411
+ }
412
+
413
+ function signupCopy(lang) {
414
+ return COPY[toSignupLanguage(lang) || 'en'];
415
+ }
416
+
417
+ function legalCopy(lang) {
418
+ return LEGAL[toSignupLanguage(lang) || 'en'];
419
+ }
420
+
421
+ // The profile draft the talent confirms before the account exists: one line per group, empty parts left out.
422
+ function renderDraft(lang, d = {}) {
423
+ const c = signupCopy(lang);
424
+ const has = (v) => v !== null && v !== undefined && String(v).trim() !== '';
425
+ const place = [d.city, countryName(lang, d.country)].filter(has).join(', ');
426
+ const situation = c.situationLabels[d.situation];
427
+ const participation = d.situation === 'EMPLOYED' ? c.participationLabels[d.participation] : null;
428
+ const lines = [
429
+ joinParts([d.name, d.role, place]),
430
+ situation ? `${c.keptLabels.workSituation}: ${participation ? `${situation} (${participation})` : situation}` : '',
431
+ joinParts([has(d.yearsOfExperience) ? c.years(d.yearsOfExperience) : null, d.stack]),
432
+ joinParts([d.languages, d.workMode, has(d.monthlyHours) ? c.hours(d.monthlyHours) : null]),
433
+ has(d.hourlyRate) || has(d.annualRate) ? c.rates(has(d.hourlyRate) ? d.hourlyRate : null, has(d.annualRate) ? d.annualRate : null, d.ratesFromTalent !== true) : '',
434
+ ].filter(Boolean);
435
+ return [c.draftTitle, ...lines].join('\n');
436
+ }
437
+
438
+ // One role of the main-role step: name, why it fits (source + one fact) and, when known, how many open projects want it.
439
+ function renderRole(lang, { name, source, evidence, openProjects, recommended }) {
440
+ const c = signupCopy(lang);
441
+ const reason = c.roleReason[source] || null;
442
+ const why = reason && evidence ? `${reason}: ${evidence}` : reason || evidence || null;
443
+ return [
444
+ recommended ? c.roleRecommended(name) : c.roleName(name),
445
+ why,
446
+ typeof openProjects === 'number' && openProjects > 0 ? c.openProjects(openProjects) : null,
447
+ ].filter(Boolean).join('\n');
448
+ }
449
+
450
+ module.exports = { SIGNUP_LANGUAGES, LEGAL, signupCopy, legalCopy, toSignupLanguage, renderDraft, renderRole, amount };
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ const { toSignupLanguage } = require('./signup-copy');
4
+
5
+ // The talent's language: the welcome fixes it from their first message and every later sign-up text keeps it.
6
+ let fixed = null;
7
+
8
+ function fixSignupLanguage(code) {
9
+ fixed = toSignupLanguage(code);
10
+ return fixed;
11
+ }
12
+
13
+ function signupLanguage(fallback = 'en') {
14
+ return fixed || toSignupLanguage(fallback) || 'en';
15
+ }
16
+
17
+ function resetSignupLanguage() {
18
+ fixed = null;
19
+ }
20
+
21
+ module.exports = { fixSignupLanguage, signupLanguage, resetSignupLanguage };