shakers-cli-beta 0.13.0 → 0.14.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 +28 -3
- package/bin/mcp.js +13 -7
- package/bin/register.js +5 -2
- package/package.json +1 -1
- package/src/backend-request.js +12 -8
- package/src/config.js +17 -5
- package/src/consent-flow.js +10 -10
- package/src/i18n.js +6 -6
- package/src/mcp-ai-usage-result-tool.js +4 -2
- package/src/mcp-ai-usage-tool.js +3 -1
- package/src/mcp-alma-tools.js +7 -3
- package/src/mcp-applications-tools.js +4 -2
- package/src/mcp-availability-tools.js +4 -2
- package/src/mcp-certifications-tools.js +4 -2
- package/src/mcp-certify-dimension-tools.js +4 -2
- package/src/mcp-choice.js +27 -1
- package/src/mcp-device-login-tools.js +6 -2
- package/src/mcp-find-projects-tools.js +4 -2
- package/src/mcp-instructions.js +1 -1
- package/src/mcp-invitations-tools.js +4 -2
- package/src/mcp-lang-tools.js +4 -2
- package/src/mcp-onboarding-tools.js +41 -44
- package/src/mcp-portfolios-tools.js +7 -3
- package/src/mcp-profile-tools.js +4 -2
- package/src/mcp-prompts.js +2 -2
- package/src/mcp-rate-tools.js +4 -2
- package/src/mcp-register-tools.js +52 -19
- package/src/mcp-report-tools.js +7 -3
- package/src/mcp-role-tools.js +23 -8
- package/src/mcp-saved-positions-tools.js +7 -3
- package/src/mcp-server.js +8 -2
- package/src/mcp-show-project-tools.js +4 -2
- package/src/mcp-signup-tools.js +153 -20
- package/src/mcp-socials-tools.js +4 -2
- package/src/onboarding-flow.js +112 -13
- package/src/onboarding-funnel.js +272 -0
- package/src/share.js +4 -3
- package/src/signup-copy.js +55 -1
- package/src/mcp-ask-tools.js +0 -50
package/src/onboarding-flow.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { NOOP_FUNNEL, sourceTypeFor, weeklyHours, rateTypes, workSituationValue } = require('./onboarding-funnel');
|
|
4
|
+
|
|
3
5
|
const FREELANCE_TYPES = ['FREELANCE', 'EMPLOYEE', 'AGENCY', 'POTENTIAL_FREELANCE'];
|
|
4
6
|
const CURRENCIES = ['USD', 'EUR', 'GBP'];
|
|
5
7
|
const PRICE_MIN = 0;
|
|
@@ -669,12 +671,33 @@ function interviewDisclaimers(lang) {
|
|
|
669
671
|
return { infoAccessed: c.interviewInfoAccessed, goalDuration: c.interviewGoalDuration };
|
|
670
672
|
}
|
|
671
673
|
|
|
674
|
+
function trackImport(funnel, attempted, importRes, durationMs) {
|
|
675
|
+
const bySource = new Map();
|
|
676
|
+
const report = importRes.ok && importRes.report ? importRes.report.sources : [];
|
|
677
|
+
for (const src of report) bySource.set(sourceTypeFor(src.source), src);
|
|
678
|
+
const connected = [];
|
|
679
|
+
for (const type of attempted) {
|
|
680
|
+
const src = bySource.get(type);
|
|
681
|
+
const ok = importRes.ok && (!src || src.status === 'imported');
|
|
682
|
+
if (ok) {
|
|
683
|
+
connected.push(type);
|
|
684
|
+
funnel.track('source_import_completed', 'import_sources', { source_type: type, duration_ms: durationMs });
|
|
685
|
+
} else {
|
|
686
|
+
funnel.track('source_import_failed', 'import_sources', { source_type: type, duration_ms: durationMs, failure_reason: (src && src.code) || importRes.reason });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return connected;
|
|
690
|
+
}
|
|
691
|
+
|
|
672
692
|
// Blocking orchestrator used by the CLI (`bin/onboarding.js`).
|
|
673
|
-
async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrowser, onAuthUrl, openProfile } = {}) {
|
|
693
|
+
async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrowser, onAuthUrl, openProfile, funnel = NOOP_FUNNEL } = {}) {
|
|
674
694
|
const { getCatalog } = require('./i18n');
|
|
675
695
|
const c = getCatalog(io.lang).onboarding;
|
|
676
696
|
|
|
697
|
+
funnel.begin();
|
|
698
|
+
funnel.completed('start');
|
|
677
699
|
io.section(c.signupTitle);
|
|
700
|
+
funnel.viewed('account_creation');
|
|
678
701
|
|
|
679
702
|
if (method === 'google' || method === 'linkedin') {
|
|
680
703
|
// The provider supplies name/email; freelanceType is derived later from work situation.
|
|
@@ -688,6 +711,8 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
688
711
|
io.error(c.signupError);
|
|
689
712
|
return { ok: false, reason: reg.reason, step: 'signup' };
|
|
690
713
|
}
|
|
714
|
+
funnel.completed('account_creation', { auth_method: method, is_returning_account: reg.kind === 'account-already-exists' });
|
|
715
|
+
funnel.identified();
|
|
691
716
|
if (reg.kind === 'account-already-exists') {
|
|
692
717
|
// Existing account: already signed in — stop, no wizard re-run.
|
|
693
718
|
io.warn(c.accountExists);
|
|
@@ -713,6 +738,8 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
713
738
|
io.warn(c.accountExists);
|
|
714
739
|
const logged = await io.login(account.email);
|
|
715
740
|
if (!logged) return { ok: false, reason: 'login-failed', step: 'signup' };
|
|
741
|
+
funnel.completed('account_creation', { auth_method: 'email', is_returning_account: true });
|
|
742
|
+
funnel.identified();
|
|
716
743
|
return { ok: true, alreadyRegistered: true };
|
|
717
744
|
}
|
|
718
745
|
const sess = await establishSessionFromCredentials(deps, { email: account.email, password: account.password });
|
|
@@ -720,12 +747,19 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
720
747
|
io.error(c.sessionError);
|
|
721
748
|
return { ok: false, reason: sess.reason, step: 'signup' };
|
|
722
749
|
}
|
|
750
|
+
funnel.completed('account_creation', { auth_method: 'email', is_returning_account: false });
|
|
751
|
+
funnel.identified();
|
|
723
752
|
notifyOk(io, c.accountReady);
|
|
724
753
|
}
|
|
725
754
|
|
|
726
755
|
io.section(c.title);
|
|
756
|
+
funnel.viewed('import_sources');
|
|
727
757
|
const linkedinUrl = (await io.ask(c.askLinkedin) || '').trim();
|
|
728
|
-
if (!linkedinUrl)
|
|
758
|
+
if (!linkedinUrl) {
|
|
759
|
+
funnel.errored('import_sources', 'no-linkedin', { error_field: 'linkedin' });
|
|
760
|
+
funnel.flush();
|
|
761
|
+
return { ok: false, reason: 'no-linkedin', step: 'import' };
|
|
762
|
+
}
|
|
729
763
|
const cvPath = (await io.ask(c.askCv) || '').trim();
|
|
730
764
|
const githubUrl = (await io.ask(c.askGithub) || '').trim();
|
|
731
765
|
const websiteUrl = (await io.ask(c.askWebsite) || '').trim();
|
|
@@ -733,45 +767,95 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
733
767
|
// The hub import is SYNCHRONOUS (the POST returns once it finishes), so show the
|
|
734
768
|
// loader AROUND that wait, then the real outcome. No background job to poll.
|
|
735
769
|
const withProgress = typeof io.withProgress === 'function' ? io.withProgress : (_l, task) => task();
|
|
770
|
+
const attempted = [linkedinUrl && 'linkedin', cvPath && 'cv', githubUrl && 'github', websiteUrl && 'web_page'].filter(Boolean);
|
|
771
|
+
for (const type of attempted) funnel.track('source_import_started', 'import_sources', { source_type: type });
|
|
772
|
+
const importStartedAt = Date.now();
|
|
736
773
|
const importRes = await withProgress(c.importChecking, () =>
|
|
737
774
|
startProfileImport(deps, { linkedinUrl, cvPath, githubUrl, websiteUrl, language: io.lang }));
|
|
775
|
+
const connected = trackImport(funnel, attempted, importRes, Date.now() - importStartedAt);
|
|
776
|
+
funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: connected, sources_connected_count: connected.length });
|
|
777
|
+
funnel.flush();
|
|
738
778
|
if (importRes.ok) notifyOk(io, c.importDone);
|
|
739
779
|
else io.warn(c.importUnavailable);
|
|
740
780
|
|
|
781
|
+
// Steps 4-8 are saved out of funnel order: sent together once all are answered.
|
|
741
782
|
io.section(c.professionalTitle);
|
|
783
|
+
funnel.viewed('work_situation');
|
|
742
784
|
const workSituationInput = await io.askWorkSituation(c);
|
|
743
785
|
const workSituationRes = await saveWorkSituation(deps, workSituationInput);
|
|
744
|
-
if (!workSituationRes.ok)
|
|
786
|
+
if (!workSituationRes.ok) {
|
|
787
|
+
funnel.errored('work_situation', workSituationRes.reason);
|
|
788
|
+
funnel.flush();
|
|
789
|
+
return { ok: false, reason: workSituationRes.reason, step: 'work-situation' };
|
|
790
|
+
}
|
|
791
|
+
funnel.completed('work_situation', { work_situation: workSituationValue(String(workSituationInput.situation || '').trim().toUpperCase()) });
|
|
745
792
|
notifyOk(io, c.professionalSaved);
|
|
746
793
|
|
|
747
794
|
io.section(c.pricingTitle);
|
|
795
|
+
funnel.viewed('rates');
|
|
748
796
|
const pricingInput = await io.askPricing(c);
|
|
749
797
|
const priceRes = await savePricing(deps, pricingInput);
|
|
750
|
-
if (!priceRes.ok)
|
|
798
|
+
if (!priceRes.ok) {
|
|
799
|
+
funnel.errored('rates', priceRes.reason);
|
|
800
|
+
funnel.flush();
|
|
801
|
+
return { ok: false, reason: priceRes.reason, step: 'pricing' };
|
|
802
|
+
}
|
|
803
|
+
const selectedRates = rateTypes(pricingInput);
|
|
804
|
+
funnel.completed('rates', {
|
|
805
|
+
rate_types_selected: selectedRates,
|
|
806
|
+
rate_type: selectedRates[0],
|
|
807
|
+
rate_currency: normalizeCurrency(pricingInput.partTimeSelected === true ? pricingInput.partTimeCurrency : pricingInput.fullTimeCurrency) || undefined,
|
|
808
|
+
});
|
|
751
809
|
notifyOk(io, c.pricingSaved);
|
|
752
810
|
|
|
753
811
|
// Optional/skippable and fail-soft: a decline or a failed write never aborts the alta.
|
|
754
812
|
io.section(c.availabilityTitle);
|
|
813
|
+
funnel.viewed('availability');
|
|
755
814
|
if (await io.confirm(c.availabilityAsk)) {
|
|
756
815
|
const availabilityInput = await io.askAvailability(c);
|
|
757
816
|
const availRes = await saveAvailability(deps, availabilityInput);
|
|
758
|
-
if (availRes.ok)
|
|
817
|
+
if (availRes.ok) {
|
|
818
|
+
funnel.completed('availability', { availability_hours_per_week: weeklyHours(normalizeMonthlyHours(availabilityInput.monthlyHours)) });
|
|
819
|
+
const country = normalizeCountry(availabilityInput.country);
|
|
820
|
+
const modes = Array.isArray(availabilityInput.workModes) ? availabilityInput.workModes.map((m) => String(m).trim().toUpperCase()) : [];
|
|
821
|
+
funnel.completed('location', {
|
|
822
|
+
location_country: country ? country.toLowerCase() : undefined,
|
|
823
|
+
location_method: 'manual',
|
|
824
|
+
is_remote_only: modes.length ? modes.every((m) => m === 'REMOTE') : undefined,
|
|
825
|
+
});
|
|
826
|
+
notifyOk(io, c.availabilitySaved);
|
|
827
|
+
} else {
|
|
828
|
+
funnel.errored('availability', availRes.reason);
|
|
829
|
+
io.warn(c.availabilityUnavailable);
|
|
830
|
+
}
|
|
759
831
|
if (availabilityInput.telephoneCode || availabilityInput.telephoneNumber) {
|
|
760
832
|
const phoneRes = await savePhone(deps, availabilityInput);
|
|
761
833
|
if (phoneRes.ok) notifyOk(io, c.phoneSaved); else io.warn(c.phoneUnavailable);
|
|
762
834
|
}
|
|
763
835
|
} else {
|
|
836
|
+
funnel.skipped('availability');
|
|
837
|
+
funnel.skipped('location');
|
|
764
838
|
io.notify(c.availabilitySkipped);
|
|
765
839
|
}
|
|
766
840
|
|
|
767
841
|
io.section(c.languagesTitle);
|
|
842
|
+
funnel.viewed('languages');
|
|
768
843
|
if (await io.confirm(c.languagesAsk)) {
|
|
769
844
|
const languageRows = await io.askLanguages(c);
|
|
770
845
|
const langRes = await saveLanguages(deps, languageRows);
|
|
771
|
-
if (langRes.ok)
|
|
846
|
+
if (langRes.ok) {
|
|
847
|
+
const codes = validateLanguageRows(languageRows).rows.map((r) => r.language);
|
|
848
|
+
funnel.completed('languages', { languages_count: codes.length, languages: codes });
|
|
849
|
+
notifyOk(io, c.languagesSaved);
|
|
850
|
+
} else {
|
|
851
|
+
funnel.errored('languages', langRes.reason);
|
|
852
|
+
io.warn(c.languagesUnavailable);
|
|
853
|
+
}
|
|
772
854
|
} else {
|
|
855
|
+
funnel.skipped('languages');
|
|
773
856
|
io.notify(c.languagesSkipped);
|
|
774
857
|
}
|
|
858
|
+
funnel.flush();
|
|
775
859
|
|
|
776
860
|
const prior = await detectPriorUsage(deps);
|
|
777
861
|
let didUsage = false;
|
|
@@ -811,24 +895,36 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
811
895
|
return url;
|
|
812
896
|
};
|
|
813
897
|
|
|
898
|
+
const mainRole = async () => {
|
|
899
|
+
const role = await runMainRoleFinalStep(io, deps);
|
|
900
|
+
if (role && role.ok && role.chosen) funnel.completed('supercluster', { supercluster: role.chosen });
|
|
901
|
+
else if (role && role.ok) funnel.skipped('supercluster');
|
|
902
|
+
funnel.flush();
|
|
903
|
+
};
|
|
904
|
+
|
|
814
905
|
io.section(c.interviewTitle);
|
|
906
|
+
funnel.viewed('pre_interview');
|
|
815
907
|
io.disclaimer(c.interviewInfoAccessed, c.interviewGoalDuration);
|
|
816
908
|
if (!(await io.confirm(c.interviewAccept))) {
|
|
817
|
-
|
|
909
|
+
funnel.completed('pre_interview', { interview_choice: 'skip' });
|
|
910
|
+
await mainRole();
|
|
818
911
|
const fin = await finalize();
|
|
819
912
|
const url = showProfile();
|
|
820
913
|
return { ok: true, completed: 'skipped-interview', didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
|
|
821
914
|
}
|
|
915
|
+
funnel.completed('pre_interview', { interview_choice: 'start_now' });
|
|
822
916
|
|
|
823
|
-
const interview = await conductLivekitInterview(io, deps);
|
|
917
|
+
const interview = await conductLivekitInterview(io, deps, { funnel });
|
|
824
918
|
if (!interview.ok) {
|
|
825
|
-
|
|
919
|
+
funnel.errored('interview', interview.reason);
|
|
920
|
+
await mainRole();
|
|
826
921
|
const fin = await finalize();
|
|
827
922
|
const url = showProfile();
|
|
828
923
|
return { ok: true, completed: 'interview-unavailable', reason: interview.reason, didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
|
|
829
924
|
}
|
|
925
|
+
funnel.completed('interview', { interview_mode: 'text' });
|
|
830
926
|
notifyOk(io, c.interviewComplete);
|
|
831
|
-
await
|
|
927
|
+
await mainRole();
|
|
832
928
|
const fin = await finalize();
|
|
833
929
|
const url = showProfile();
|
|
834
930
|
return { ok: true, completed: 'interview', didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
|
|
@@ -860,7 +956,7 @@ function livekitTurnBridge(client, transcript) {
|
|
|
860
956
|
}
|
|
861
957
|
|
|
862
958
|
// The onboarding/dimension interview over LiveKit text transport. LiveKit Node SDK is an optional lazily-required dep; a `livekit-not-installed` connect prints a hint, not a stack trace. Persists the transcript on session end even when interrupted; a failed PATCH warns, never crashes.
|
|
863
|
-
async function conductLivekitInterview(io, deps, { interviewId = null, language = null, plain = false } = {}) {
|
|
959
|
+
async function conductLivekitInterview(io, deps, { interviewId = null, language = null, plain = false, funnel = null } = {}) {
|
|
864
960
|
const { getCatalog } = require('./i18n');
|
|
865
961
|
const c = getCatalog(io.lang).onboarding;
|
|
866
962
|
const { TranscriptBuffer } = require('./interview-transcript');
|
|
@@ -870,6 +966,7 @@ async function conductLivekitInterview(io, deps, { interviewId = null, language
|
|
|
870
966
|
? await startLivekitForInterview(deps, { interviewId, language: language || io.lang })
|
|
871
967
|
: await createAndStartLivekitOnboarding(deps, { language: language || io.lang });
|
|
872
968
|
if (!start.ok) return { ok: false, reason: start.reason, step: start.step || 'interview' };
|
|
969
|
+
if (funnel) funnel.viewed('interview', { interview_mode: 'text' });
|
|
873
970
|
|
|
874
971
|
const closing = start.closingMessage;
|
|
875
972
|
const isClosing = closing ? (t) => typeof t === 'string' && t.includes(closing) : null;
|
|
@@ -920,8 +1017,10 @@ async function runMainRoleFinalStep(io, deps, session = null) {
|
|
|
920
1017
|
const catalog = getCatalog(io.lang);
|
|
921
1018
|
try {
|
|
922
1019
|
const rolesDeps = typeof deps.makeRolesDeps === 'function' ? deps.makeRolesDeps() : undefined;
|
|
923
|
-
await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps });
|
|
924
|
-
} catch {
|
|
1020
|
+
return await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps });
|
|
1021
|
+
} catch {
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
925
1024
|
}
|
|
926
1025
|
|
|
927
1026
|
// Overwrite-repeat: PATCH /interviews/onboarding/restart. Per contract the Bearer is
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { getConfigDir } = require('./config-dir');
|
|
7
|
+
|
|
8
|
+
// Names and values mirror works-frontend onboarding-funnel-enums.ts.
|
|
9
|
+
|
|
10
|
+
const FLOW_VERSION = 'signup_new_works_v1';
|
|
11
|
+
|
|
12
|
+
const STEP_INDEX = Object.freeze({
|
|
13
|
+
start: 1,
|
|
14
|
+
account_creation: 2,
|
|
15
|
+
import_sources: 3,
|
|
16
|
+
work_situation: 4,
|
|
17
|
+
availability: 5,
|
|
18
|
+
rates: 6,
|
|
19
|
+
languages: 7,
|
|
20
|
+
location: 8,
|
|
21
|
+
profile_summary: 9,
|
|
22
|
+
pre_interview: 10,
|
|
23
|
+
interview: 11,
|
|
24
|
+
supercluster: 12,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const EVENT_TYPES = Object.freeze([
|
|
28
|
+
'onboarding_step_viewed',
|
|
29
|
+
'onboarding_step_completed',
|
|
30
|
+
'onboarding_step_skipped',
|
|
31
|
+
'onboarding_step_errored',
|
|
32
|
+
'onboarding_step_exited',
|
|
33
|
+
'source_import_started',
|
|
34
|
+
'source_import_completed',
|
|
35
|
+
'source_import_failed',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const SURFACES = Object.freeze(['cli', 'mcp']);
|
|
39
|
+
const MCP_CLIENTS = Object.freeze(['claude', 'chatgpt', 'cursor', 'other']);
|
|
40
|
+
const SOURCE_TYPES = Object.freeze(['linkedin', 'cv', 'github', 'web_page']);
|
|
41
|
+
const ERROR_TYPES = Object.freeze(['validation', 'server', 'timeout', 'auth', 'import']);
|
|
42
|
+
|
|
43
|
+
const WORK_SITUATION_VALUES = Object.freeze({
|
|
44
|
+
FREELANCE: 'freelance',
|
|
45
|
+
EMPLOYED: 'employed',
|
|
46
|
+
BETWEEN_JOBS: 'between-jobs',
|
|
47
|
+
STUDYING: 'studying',
|
|
48
|
+
OTHER: 'other',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const ONCE_PER_STEP = new Set(['onboarding_step_viewed', 'onboarding_step_completed']);
|
|
52
|
+
const MAX_EVENTS_PER_REQUEST = 50;
|
|
53
|
+
const SEND_TIMEOUT_MS = 3000;
|
|
54
|
+
const DEVICE_FILE_NAME = 'analytics-device.json';
|
|
55
|
+
|
|
56
|
+
function mcpClientFor(clientName) {
|
|
57
|
+
const name = typeof clientName === 'string' ? clientName.toLowerCase() : '';
|
|
58
|
+
if (name.includes('claude')) return 'claude';
|
|
59
|
+
if (name.includes('chatgpt') || name.includes('openai')) return 'chatgpt';
|
|
60
|
+
if (name.includes('cursor')) return 'cursor';
|
|
61
|
+
return 'other';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sourceTypeFor(source) {
|
|
65
|
+
if (source === 'linkedin' || source === 'cv' || source === 'github') return source;
|
|
66
|
+
return 'web_page';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function workSituationValue(code) {
|
|
70
|
+
return WORK_SITUATION_VALUES[code] || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Same ÷4 as the web; '> 160' has no number.
|
|
74
|
+
function weeklyHours(monthlyHours) {
|
|
75
|
+
const n = Number(monthlyHours);
|
|
76
|
+
return monthlyHours != null && monthlyHours !== '' && Number.isFinite(n) ? Math.round(n / 4) : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// As on the web: part-time project price is hourly, full-time is annual.
|
|
80
|
+
function rateTypes({ partTimeSelected, fullTimeSelected } = {}) {
|
|
81
|
+
return [partTimeSelected === true && 'hourly', fullTimeSelected === true && 'annual'].filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function errorTypeFor(reason) {
|
|
85
|
+
const r = String(reason || '');
|
|
86
|
+
if (r === 'no-session' || r === 'no-hub-token' || r === 'http-401' || r === 'http-403') return 'auth';
|
|
87
|
+
if (r === 'timeout') return 'timeout';
|
|
88
|
+
if (r === 'no-endpoint') return 'server';
|
|
89
|
+
if (r.startsWith('bad-') || r.startsWith('no-') || r === 'duplicate-language' || r === 'unresolved-languages') return 'validation';
|
|
90
|
+
return 'server';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanProperties(properties) {
|
|
94
|
+
const ok = (v) => typeof v === 'string' || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v))
|
|
95
|
+
|| (Array.isArray(v) && v.every((x) => typeof x === 'string'));
|
|
96
|
+
return Object.fromEntries(Object.entries(properties || {}).filter(([, v]) => ok(v)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function deviceFilePath(env) {
|
|
100
|
+
return path.join(getConfigDir(env), DEVICE_FILE_NAME);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function loadOrCreateDeviceId(env = process.env, { fs: fsImpl = fs } = {}) {
|
|
104
|
+
const target = deviceFilePath(env);
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(fsImpl.readFileSync(target, 'utf8'));
|
|
107
|
+
if (parsed && typeof parsed.deviceId === 'string' && parsed.deviceId) return parsed.deviceId;
|
|
108
|
+
} catch { /* create one */ }
|
|
109
|
+
const deviceId = crypto.randomUUID();
|
|
110
|
+
const dir = path.dirname(target);
|
|
111
|
+
const tmp = path.join(dir, `.${DEVICE_FILE_NAME}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
112
|
+
let fd = null;
|
|
113
|
+
try {
|
|
114
|
+
fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
115
|
+
fd = fsImpl.openSync(tmp, 'wx', 0o600);
|
|
116
|
+
fsImpl.writeFileSync(fd, `${JSON.stringify({ deviceId }, null, 2)}\n`);
|
|
117
|
+
try { fsImpl.fsyncSync(fd); } catch { /* e.g. EINVAL on some filesystems */ }
|
|
118
|
+
fsImpl.closeSync(fd);
|
|
119
|
+
fd = null;
|
|
120
|
+
fsImpl.renameSync(tmp, target);
|
|
121
|
+
return deviceId;
|
|
122
|
+
} catch {
|
|
123
|
+
if (fd !== null) { try { fsImpl.closeSync(fd); } catch { /* already closed */ } }
|
|
124
|
+
try { fsImpl.unlinkSync(tmp); } catch { /* never existed */ }
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function defaultSend({ endpoint, events, hubAccessToken }) {
|
|
130
|
+
const { postJsonWithTimeout } = require('./backend-request');
|
|
131
|
+
const res = await postJsonWithTimeout(endpoint, { events }, SEND_TIMEOUT_MS, 'POST', null, { Authorization: `Bearer ${hubAccessToken}` });
|
|
132
|
+
return { ok: res.status >= 200 && res.status < 300, status: res.status };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function defaultHubToken() {
|
|
136
|
+
const { loadAuthSession, sessionStatus } = require('./auth-session-store');
|
|
137
|
+
const session = loadAuthSession();
|
|
138
|
+
return sessionStatus(session) === 'active' && session && session.hubAccessToken ? session.hubAccessToken : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createFunnel({
|
|
142
|
+
surface,
|
|
143
|
+
hubToken = defaultHubToken,
|
|
144
|
+
deviceId = () => loadOrCreateDeviceId(),
|
|
145
|
+
endpoint = () => require('./config').getOnboardingEventsEndpoint(),
|
|
146
|
+
send = defaultSend,
|
|
147
|
+
now = () => Date.now(),
|
|
148
|
+
uuid = () => crypto.randomUUID(),
|
|
149
|
+
} = {}) {
|
|
150
|
+
if (!SURFACES.includes(surface)) throw new Error(`unknown funnel surface: ${surface}`);
|
|
151
|
+
let pending = [];
|
|
152
|
+
let lastTime = 0;
|
|
153
|
+
let seq = 0;
|
|
154
|
+
let ready = false;
|
|
155
|
+
let account = null;
|
|
156
|
+
let active = false;
|
|
157
|
+
let mcpClient = null;
|
|
158
|
+
let cachedDeviceId;
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
const inflight = new Set();
|
|
161
|
+
|
|
162
|
+
function track(eventType, stepName, properties = {}) {
|
|
163
|
+
if (!active || !EVENT_TYPES.includes(eventType) || !STEP_INDEX[stepName]) return false;
|
|
164
|
+
if (ONCE_PER_STEP.has(eventType)) {
|
|
165
|
+
const key = `${eventType}:${stepName}`;
|
|
166
|
+
if (seen.has(key)) return false;
|
|
167
|
+
seen.add(key);
|
|
168
|
+
}
|
|
169
|
+
const common = { step_name: stepName, step_index: STEP_INDEX[stepName], flow_version: FLOW_VERSION, surface };
|
|
170
|
+
if (surface === 'mcp') common.mcp_client = mcpClient || 'other';
|
|
171
|
+
pending.push({ eventType, insertId: uuid(), at: now(), seq: seq++, properties: { ...cleanProperties(properties), ...common } });
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Funnel order, each time strictly after the previous one sent.
|
|
176
|
+
function stamp(batch) {
|
|
177
|
+
const ordered = [...batch].sort((a, b) => a.properties.step_index - b.properties.step_index || a.seq - b.seq);
|
|
178
|
+
const id = cachedDeviceId === undefined ? (cachedDeviceId = safe(deviceId)) : cachedDeviceId;
|
|
179
|
+
return ordered.map((e) => {
|
|
180
|
+
lastTime = Math.max(e.at, lastTime + 1);
|
|
181
|
+
return { event_type: e.eventType, insert_id: e.insertId, time: lastTime, ...(id ? { device_id: id } : {}), properties: e.properties };
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function flush() {
|
|
186
|
+
if (!ready || !pending.length) return Promise.resolve();
|
|
187
|
+
const token = safe(hubToken);
|
|
188
|
+
const url = safe(endpoint);
|
|
189
|
+
// Never send under another account signed in since.
|
|
190
|
+
if (!token || !url || accountOf(token) !== account) return Promise.resolve();
|
|
191
|
+
const events = stamp(pending);
|
|
192
|
+
pending = [];
|
|
193
|
+
const sends = [];
|
|
194
|
+
for (let i = 0; i < events.length; i += MAX_EVENTS_PER_REQUEST) {
|
|
195
|
+
sends.push(Promise.resolve().then(() => send({ endpoint: url, events: events.slice(i, i + MAX_EVENTS_PER_REQUEST), hubAccessToken: token })).catch(() => null));
|
|
196
|
+
}
|
|
197
|
+
const done = Promise.all(sends).then(() => undefined, () => undefined);
|
|
198
|
+
inflight.add(done);
|
|
199
|
+
done.then(() => inflight.delete(done));
|
|
200
|
+
return done;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
surface,
|
|
205
|
+
begin() {
|
|
206
|
+
seen.clear();
|
|
207
|
+
active = true;
|
|
208
|
+
},
|
|
209
|
+
get active() { return active; },
|
|
210
|
+
setMcpClient(clientName) { mcpClient = mcpClientFor(clientName); },
|
|
211
|
+
track,
|
|
212
|
+
viewed: (step, props) => track('onboarding_step_viewed', step, props),
|
|
213
|
+
completed: (step, props) => track('onboarding_step_completed', step, props),
|
|
214
|
+
skipped: (step, props) => track('onboarding_step_skipped', step, props),
|
|
215
|
+
errored: (step, reason, props = {}) => track('onboarding_step_errored', step, { error_type: errorTypeFor(reason), ...props }),
|
|
216
|
+
identified() {
|
|
217
|
+
const token = safe(hubToken);
|
|
218
|
+
if (!token) return Promise.resolve();
|
|
219
|
+
account = accountOf(token);
|
|
220
|
+
ready = true;
|
|
221
|
+
return flush();
|
|
222
|
+
},
|
|
223
|
+
flush,
|
|
224
|
+
idle: () => Promise.all([...inflight]).then(() => undefined),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A refreshed token keeps the same account.
|
|
229
|
+
function accountOf(token) {
|
|
230
|
+
const payload = require('./auth-session-store').decodeJwtPayload(token);
|
|
231
|
+
return (payload && (payload.sub || payload.email)) || token;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function safe(fn) {
|
|
235
|
+
try { return fn(); } catch { return null; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const NOOP_FUNNEL = Object.freeze({
|
|
239
|
+
surface: null,
|
|
240
|
+
begin() {},
|
|
241
|
+
active: false,
|
|
242
|
+
setMcpClient() {},
|
|
243
|
+
track: () => false,
|
|
244
|
+
viewed: () => false,
|
|
245
|
+
completed: () => false,
|
|
246
|
+
skipped: () => false,
|
|
247
|
+
errored: () => false,
|
|
248
|
+
identified: () => Promise.resolve(),
|
|
249
|
+
flush: () => Promise.resolve(),
|
|
250
|
+
idle: () => Promise.resolve(),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
FLOW_VERSION,
|
|
255
|
+
STEP_INDEX,
|
|
256
|
+
EVENT_TYPES,
|
|
257
|
+
SURFACES,
|
|
258
|
+
MCP_CLIENTS,
|
|
259
|
+
SOURCE_TYPES,
|
|
260
|
+
ERROR_TYPES,
|
|
261
|
+
MAX_EVENTS_PER_REQUEST,
|
|
262
|
+
DEVICE_FILE_NAME,
|
|
263
|
+
NOOP_FUNNEL,
|
|
264
|
+
createFunnel,
|
|
265
|
+
mcpClientFor,
|
|
266
|
+
sourceTypeFor,
|
|
267
|
+
workSituationValue,
|
|
268
|
+
weeklyHours,
|
|
269
|
+
rateTypes,
|
|
270
|
+
errorTypeFor,
|
|
271
|
+
loadOrCreateDeviceId,
|
|
272
|
+
};
|
package/src/share.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
|
|
5
|
-
const { getIngestEndpoint
|
|
5
|
+
const { getIngestEndpoint } = require('./config');
|
|
6
6
|
const { requestBackend } = require('./backend-request');
|
|
7
7
|
const { recordBackendAcceptance } = require('./report-store');
|
|
8
8
|
const {
|
|
@@ -53,8 +53,9 @@ async function autoShare(report, maturity, { root, bypassThrottle = false } = {}
|
|
|
53
53
|
if (!state.email) {
|
|
54
54
|
return { ok: false, skipped: true, reason: 'no-email' };
|
|
55
55
|
}
|
|
56
|
-
// ADR-006: never SEND under an email whose ownership wasn't proven
|
|
57
|
-
|
|
56
|
+
// ADR-006: never SEND under an email whose ownership wasn't proven — every
|
|
57
|
+
// profile, `external` included (legal requirement), must have verified.
|
|
58
|
+
if (state.emailVerified === false) {
|
|
58
59
|
return { ok: false, skipped: true, reason: 'email-unverified' };
|
|
59
60
|
}
|
|
60
61
|
if (!bypassThrottle && isThrottled(state)) {
|
package/src/signup-copy.js
CHANGED
|
@@ -52,6 +52,16 @@ function joinParts(parts) {
|
|
|
52
52
|
return parts.filter((p) => p !== null && p !== undefined && String(p).trim() !== '').join(' · ');
|
|
53
53
|
}
|
|
54
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
|
+
|
|
55
65
|
function amount(lang, value) {
|
|
56
66
|
return new Intl.NumberFormat(LOCALES[lang], { maximumFractionDigits: 0 }).format(Number(value));
|
|
57
67
|
}
|
|
@@ -95,11 +105,21 @@ const COPY = {
|
|
|
95
105
|
existingNo: 'No, déjalo como está',
|
|
96
106
|
existingKept: 'Perfecto, dejo tu perfil como está.',
|
|
97
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' },
|
|
98
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.`,
|
|
99
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.',
|
|
100
112
|
aiUsageYes: 'Sí, analízalo',
|
|
101
113
|
aiUsageSkip: 'Saltar este paso',
|
|
102
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
|
+
},
|
|
103
123
|
importSources: { linkedin: 'tu LinkedIn', cv: 'tu CV', github: 'tu GitHub', web: 'tu web' },
|
|
104
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.`,
|
|
105
125
|
interviewWhereQuestion: '¿Hacemos la entrevista de onboarding aquí ahora o luego en la web?',
|
|
@@ -169,11 +189,21 @@ const COPY = {
|
|
|
169
189
|
existingNo: 'No, leave it as it is',
|
|
170
190
|
existingKept: 'Sure, I am leaving your profile as it is.',
|
|
171
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' },
|
|
172
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.`,
|
|
173
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.',
|
|
174
196
|
aiUsageYes: 'Yes, analyse it',
|
|
175
197
|
aiUsageSkip: 'Skip this step',
|
|
176
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
|
+
},
|
|
177
207
|
importSources: { linkedin: 'your LinkedIn', cv: 'your CV', github: 'your GitHub', web: 'your website' },
|
|
178
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.`,
|
|
179
209
|
interviewWhereQuestion: 'Shall we do the onboarding interview here now or later on the web?',
|
|
@@ -243,11 +273,21 @@ const COPY = {
|
|
|
243
273
|
existingNo: 'No, lascialo così',
|
|
244
274
|
existingKept: 'Perfetto, lascio il tuo profilo così com’è.',
|
|
245
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' },
|
|
246
278
|
existingUnchanged: (items) => `Questi dati del tuo profilo non li ho cambiati, restano com'erano: ${items.join(' · ')}. Se vuoi altri valori, cambiali sul sito.`,
|
|
247
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.",
|
|
248
280
|
aiUsageYes: 'Sì, analizzalo',
|
|
249
281
|
aiUsageSkip: 'Salta questo passaggio',
|
|
250
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
|
+
},
|
|
251
291
|
importSources: { linkedin: 'il tuo LinkedIn', cv: 'il tuo CV', github: 'il tuo GitHub', web: 'il tuo sito' },
|
|
252
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.`,
|
|
253
293
|
interviewWhereQuestion: 'Facciamo il colloquio di onboarding qui adesso o più tardi sul sito?',
|
|
@@ -317,11 +357,21 @@ const COPY = {
|
|
|
317
357
|
existingNo: 'Não, deixe como está',
|
|
318
358
|
existingKept: 'Perfeito, deixo o seu perfil como está.',
|
|
319
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' },
|
|
320
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.`,
|
|
321
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.',
|
|
322
364
|
aiUsageYes: 'Sim, analise',
|
|
323
365
|
aiUsageSkip: 'Saltar este passo',
|
|
324
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
|
+
},
|
|
325
375
|
importSources: { linkedin: 'o seu LinkedIn', cv: 'o seu CV', github: 'o seu GitHub', web: 'o seu site' },
|
|
326
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.`,
|
|
327
377
|
interviewWhereQuestion: 'Fazemos a entrevista de onboarding aqui agora ou mais tarde na web?',
|
|
@@ -372,8 +422,12 @@ function legalCopy(lang) {
|
|
|
372
422
|
function renderDraft(lang, d = {}) {
|
|
373
423
|
const c = signupCopy(lang);
|
|
374
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;
|
|
375
428
|
const lines = [
|
|
376
|
-
joinParts([d.name, d.role,
|
|
429
|
+
joinParts([d.name, d.role, place]),
|
|
430
|
+
situation ? `${c.keptLabels.workSituation}: ${participation ? `${situation} (${participation})` : situation}` : '',
|
|
377
431
|
joinParts([has(d.yearsOfExperience) ? c.years(d.yearsOfExperience) : null, d.stack]),
|
|
378
432
|
joinParts([d.languages, d.workMode, has(d.monthlyHours) ? c.hours(d.monthlyHours) : null]),
|
|
379
433
|
has(d.hourlyRate) || has(d.annualRate) ? c.rates(has(d.hourlyRate) ? d.hourlyRate : null, has(d.annualRate) ? d.annualRate : null, d.ratesFromTalent !== true) : '',
|