mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
3d696c81f9
* feat(web): revise onboarding "hear about us" source options Replace the 11-item discovery-source list on the onboarding About-you step with a 14-item list (13 named channels + Other), reordered by expected volume and de-duplicated: - Merge Instagram + Threads into a single "Instagram / Threads / Facebook" chip (value meta_social). - Add TikTok, Discord / community, Email / Newsletter, and Other. - Normalize the AI-tool value to ai_tool and clarify labels with brand examples (Search (Google / Bing), AI tool (ChatGPT / Perplexity)). i18n: rebuild the onboardingSource block across all 19 locales. zh-CN / zh-TW / fr are localized; the remaining English-fallback locales now all share one identical up-to-date English string, removing prior stale wording drift. * feat(web): add free-text detail for the onboarding "Other" source When a visitor picks "Other" on the "Where did you hear about us?" step, reveal an optional text input so they can name the actual channel. The raw text is kept separate from the aggregatable `other` bucket: - source stays 'other' for funnel splits; the typed detail rides along as `sourceOther` (persisted, capped, only kept while source==='other'). - PostHog: person property `od_onboarding_source_other` and the about_you_submit event property `discovery_source_other`. - Memory profile body appends the detail as "Other (<text>)". i18n: new onboardingSourceOtherPlaceholder across all 19 locales. Tests cover person-property capture, stale-detail drop, and storage round-trip. * fix(web): render the onboarding "Other" input inline with its chip The free-text detail previously floated on its own line below the source group, reading as detached from the "Other" chip that reveals it. Give OnboardingChipField an optional `trailing` slot rendered at the end of the chip row and pass the input through it, so it sits directly after the Other chip (filling the rest of the row, or wrapping at a comfortable width) and matches the chip height. Autofocus on reveal. * fix(web): mirror discovery_source_other onto the completion-result carrier Review follow-up: the onboarding_complete_result event is the fallback mirror of about_you_submit, preserving the survey snapshot when the individual click is dropped on navigation. It carried discovery_source but not the new discovery_source_other, so the fallback path kept discovery_source: 'other' while losing the raw channel the user typed. Extend OnboardingCompleteResultProps and the completion payload to carry discovery_source_other when liveProfile.source === 'other', and add a regression asserting both snapshot carriers stay in parity. * i18n(web): localize the onboarding About-you survey across all locales The onboarding "About you" block (role / org size / use case / discovery source options + their labels, placeholders, and the profile heading) shipped English-only for every locale except ru / zh-CN / zh-TW when it was introduced (#4744) — the surrounding onboarding steps were already translated, so this block read as a conspicuous English island. Localize all 49 About-you strings into the 14 English-fallback locales (ar, de, es-ES, fa, hu, id, it, ja, ko, pl, pt-BR, th, tr, uk) and complete the partially-translated fr. Brand names (X, GitHub, YouTube, TikTok, Reddit, LinkedIn, Instagram/Threads/Facebook, Discord, Google, Bing, ChatGPT, Perplexity), leading emoji, and numeric size ranges are preserved verbatim; only the descriptive wording is translated. * i18n(web): fully localize residual jargon in the onboarding survey Follow-up to the About-you localization: some role/use-case values kept English or transliterated business jargon (Growth, Operations, Design system, deck) that not every reader is a tech insider for. Translate these into each language's natural native word — e.g. Growth → Wachstum (de) / Croissance (fr) / Зростання (uk) / 成長 (ja) / 성장 (ko), deck → Folien (de) / слайды (ru) / 슬라이드 (ko). Universal acronyms (AI) and true brand names stay as-is. * fix(web): keep the "Other" free-text out of analytics (privacy) Review follow-up: the raw text a user types in the onboarding "Other" source box was flowing into analytics as an event property (discovery_source_other) and a durable person property (od_onboarding_source_other). Analytics events/profiles must stay free-text/PII-free, and the scrubber does not sanitize arbitrary event or person-property payloads — so an email, real name, or private note could ship to PostHog unchanged and persist indefinitely. Carry only the enumerated `other` bucket into analytics and AMR. Drop discovery_source_other from the OnboardingClickProps / OnboardingComplete ResultProps contracts and both emit paths, drop od_onboarding_source_other from the person properties, and stop persisting sourceOther to the stored attribution profile. The typed detail is preserved solely in the app-owned Memory note ("Discovery source: Other (<text>)") and the live input UI — the free-text home the contract explicitly allows. Tests now assert the raw text never reaches either analytics carrier or storage. * fix(web): keep the "Other" free-text ref in sync to avoid note data-loss Review follow-up: the Other-source free-text input updated only React state, but its sole sink — the Memory note written from `profileRef.current` — relies on a later useEffect to sync the ref. A fast type-then-Continue could read the ref before that effect ran and drop the last keystrokes (or the whole custom channel). Mirror the value into `profileRef.current` synchronously in the input's onChange so the note always reflects the latest input, removing the timing dependency. Adds a behavioral test asserting the typed Other value reaches the `/api/memory/user_profile` payload. Note: jsdom/RTL cannot reproduce the underlying race (the async memory PUT always reads the ref after the sync effect flushes), so this is a behavioral guard rather than a strict red spec; the fix removes the dependency by construction. * refactor(web): route all About-you edits through a ref-syncing helper Review follow-up: the previous fix only covered the "Other" text input, but the source chip's onChange (and the role/org/use-case/email handlers) still updated React state alone. A user could pick Other, type a channel, switch to another chip, and continue before the state→ref useEffect ran — leaving the live ref (read by emitAboutYouSubmit / the Memory note / newsletter submit) with a stale source and abandoned sourceOther. Introduce a single `updateProfile(producer)` helper that mirrors the next value into `profileRef.current` synchronously and calls setProfile, and route every About-you field through it. This removes the state/ref timing dependency for the whole survey by construction, rather than patching one field at a time. Adds a behavioral guard that switching away from Other clears the abandoned free-text from the memory note. --------- Co-authored-by: Scarlett Huang <scarletthuang@Scarletts-MacBook-Air.local>
276 lines
8.5 KiB
TypeScript
276 lines
8.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { recordAmrEntry } from '../../src/analytics/amr-attribution';
|
|
import {
|
|
readOnboardingProfile,
|
|
saveOnboardingProfile,
|
|
type OnboardingProfile,
|
|
} from '../../src/state/onboarding-profile';
|
|
|
|
vi.mock('../../src/analytics/client', () => ({
|
|
setAnalyticsPersonProperties: vi.fn(),
|
|
}));
|
|
|
|
import { setAnalyticsPersonProperties } from '../../src/analytics/client';
|
|
import {
|
|
bindSignedInUserAttributionPersonProperties,
|
|
setOnboardingAttributionPersonProperties,
|
|
} from '../../src/analytics/source-attribution';
|
|
|
|
describe('source attribution person properties', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
document.cookie = 'od_attr=;path=/;max-age=0';
|
|
vi.mocked(setAnalyticsPersonProperties).mockClear();
|
|
});
|
|
|
|
it('sets onboarding profile fields as PostHog person properties', () => {
|
|
setOnboardingAttributionPersonProperties(
|
|
{
|
|
role: 'engineer',
|
|
orgSize: 'growth',
|
|
useCase: ['product', 'unknown', ''],
|
|
source: 'github',
|
|
},
|
|
new Date('2026-07-02T08:00:00.000Z'),
|
|
);
|
|
|
|
expect(setAnalyticsPersonProperties).toHaveBeenCalledWith({
|
|
od_role: 'engineer',
|
|
od_org_size: 'growth',
|
|
od_use_cases: ['product'],
|
|
od_onboarding_source: 'github',
|
|
od_source_resolved: 'github',
|
|
od_source_resolution: 'onboarding',
|
|
od_onboarding_at: '2026-07-02T08:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
it('never persists raw "other" free-text to the stored profile', () => {
|
|
// Even if a caller supplies a free-text detail, it must never be written to
|
|
// the persisted attribution profile — only the enumerated bucket survives.
|
|
saveOnboardingProfile(
|
|
{ source: 'other', sourceOther: 'Design podcast' } as unknown as OnboardingProfile,
|
|
new Date('2026-07-02T08:00:00.000Z'),
|
|
);
|
|
const stored = readOnboardingProfile();
|
|
expect(stored).toMatchObject({ source: 'other' });
|
|
expect(stored).not.toHaveProperty('sourceOther');
|
|
});
|
|
|
|
it('never carries raw "other" free-text into analytics person properties', () => {
|
|
// Analytics profile state must stay free-text/PII-free: the enumerated
|
|
// bucket is bound, but the raw channel the user typed is never surfaced.
|
|
setOnboardingAttributionPersonProperties(
|
|
{ source: 'other', sourceOther: 'Design podcast' } as unknown as OnboardingProfile,
|
|
new Date('2026-07-02T08:00:00.000Z'),
|
|
);
|
|
|
|
const props = vi.mocked(setAnalyticsPersonProperties).mock.calls[0]?.[0] ?? {};
|
|
expect(props).toMatchObject({ od_onboarding_source: 'other' });
|
|
expect(props).not.toHaveProperty('od_onboarding_source_other');
|
|
});
|
|
|
|
it('binds a signed-in AMR user to the stored onboarding source', () => {
|
|
const onboardingCompletedAt = new Date('2026-07-01T07:00:00.000Z');
|
|
saveOnboardingProfile(
|
|
{
|
|
role: 'growth',
|
|
orgSize: 'startup',
|
|
useCase: ['marketing'],
|
|
source: 'social',
|
|
},
|
|
onboardingCompletedAt,
|
|
);
|
|
|
|
bindSignedInUserAttributionPersonProperties(
|
|
'usr_amr_42',
|
|
new Date('2026-07-02T08:30:00.000Z'),
|
|
);
|
|
|
|
expect(setAnalyticsPersonProperties).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
od_app_user_id: 'usr_amr_42',
|
|
od_source_bound_at: '2026-07-02T08:30:00.000Z',
|
|
od_source_resolved: 'social',
|
|
od_source_resolution: 'onboarding',
|
|
od_role: 'growth',
|
|
od_org_size: 'startup',
|
|
od_use_cases: ['marketing'],
|
|
od_onboarding_source: 'social',
|
|
od_onboarding_at: '2026-07-01T07:00:00.000Z',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('binds landing UTM attribution to the signed-in AMR user', () => {
|
|
writeLandingAttributionCookie({
|
|
s: 'twitter',
|
|
m: 'organic_social',
|
|
c: '202606_story',
|
|
ct: 'official',
|
|
tm: 'design_agent',
|
|
ref: 'x.com',
|
|
lp: '/stories/ikigai-one/',
|
|
t: Date.parse('2026-06-30T12:00:00.000Z'),
|
|
});
|
|
|
|
bindSignedInUserAttributionPersonProperties(
|
|
'usr_amr_42',
|
|
new Date('2026-07-02T08:30:00.000Z'),
|
|
);
|
|
|
|
expect(setAnalyticsPersonProperties).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
od_app_user_id: 'usr_amr_42',
|
|
od_source_bound_at: '2026-07-02T08:30:00.000Z',
|
|
od_utm_source: 'twitter',
|
|
od_utm_medium: 'organic_social',
|
|
od_utm_campaign: '202606_story',
|
|
od_utm_content: 'official',
|
|
od_utm_term: 'design_agent',
|
|
od_referrer: 'x.com',
|
|
od_landing_path: '/stories/ikigai-one/',
|
|
od_utm_first_touch_at: '2026-06-30T12:00:00.000Z',
|
|
od_source_resolved: 'twitter',
|
|
od_source_resolution: 'utm',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('keeps UTM and onboarding separate while resolving source from UTM first', () => {
|
|
writeLandingAttributionCookie({
|
|
s: 'linkedin',
|
|
m: 'paid_social',
|
|
c: 'launch',
|
|
ct: 'founder_post',
|
|
lp: '/download',
|
|
t: Date.parse('2026-06-30T12:00:00.000Z'),
|
|
});
|
|
saveOnboardingProfile(
|
|
{
|
|
role: 'founder',
|
|
orgSize: 'solo',
|
|
useCase: ['landing_pages'],
|
|
source: 'github',
|
|
},
|
|
new Date('2026-07-01T07:00:00.000Z'),
|
|
);
|
|
|
|
bindSignedInUserAttributionPersonProperties(
|
|
'usr_amr_42',
|
|
new Date('2026-07-02T08:30:00.000Z'),
|
|
);
|
|
|
|
expect(setAnalyticsPersonProperties).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
od_utm_source: 'linkedin',
|
|
od_utm_medium: 'paid_social',
|
|
od_utm_campaign: 'launch',
|
|
od_utm_content: 'founder_post',
|
|
od_landing_path: '/download',
|
|
od_utm_first_touch_at: '2026-06-30T12:00:00.000Z',
|
|
od_onboarding_source: 'github',
|
|
od_onboarding_at: '2026-07-01T07:00:00.000Z',
|
|
od_source_resolved: 'linkedin',
|
|
od_source_resolution: 'utm',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('falls back to referrer attribution when od_attr has no UTM source', () => {
|
|
writeLandingAttributionCookie({
|
|
ref: 'google.com',
|
|
lp: '/download',
|
|
t: Date.parse('2026-06-30T12:00:00.000Z'),
|
|
});
|
|
|
|
bindSignedInUserAttributionPersonProperties(
|
|
'usr_amr_42',
|
|
new Date('2026-07-02T08:30:00.000Z'),
|
|
);
|
|
|
|
expect(setAnalyticsPersonProperties).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
od_referrer: 'google.com',
|
|
od_landing_path: '/download',
|
|
od_utm_first_touch_at: '2026-06-30T12:00:00.000Z',
|
|
od_source_resolved: 'google.com',
|
|
od_source_resolution: 'referrer',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('ignores malformed landing attribution cookies and falls back to onboarding', () => {
|
|
document.cookie = 'od_attr=%7Bbad-json;path=/';
|
|
saveOnboardingProfile(
|
|
{
|
|
role: 'designer',
|
|
orgSize: 'startup',
|
|
useCase: ['product'],
|
|
source: 'friend',
|
|
},
|
|
new Date('2026-07-01T07:00:00.000Z'),
|
|
);
|
|
|
|
bindSignedInUserAttributionPersonProperties(
|
|
'usr_amr_42',
|
|
new Date('2026-07-02T08:30:00.000Z'),
|
|
);
|
|
|
|
expect(setAnalyticsPersonProperties).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
od_onboarding_source: 'friend',
|
|
od_source_resolved: 'friend',
|
|
od_source_resolution: 'onboarding',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('binds the stored AMR entry attribution alongside onboarding fields', () => {
|
|
saveOnboardingProfile(
|
|
{
|
|
role: 'growth',
|
|
orgSize: 'startup',
|
|
useCase: ['marketing'],
|
|
source: 'social',
|
|
},
|
|
new Date('2026-07-01T07:00:00.000Z'),
|
|
);
|
|
const track = vi.fn();
|
|
recordAmrEntry(
|
|
track,
|
|
'inline_model_switcher_amr_row',
|
|
new Date('2026-07-02T08:15:00.000Z'),
|
|
);
|
|
|
|
bindSignedInUserAttributionPersonProperties(
|
|
'usr_amr_42',
|
|
new Date('2026-07-02T08:30:00.000Z'),
|
|
);
|
|
|
|
expect(setAnalyticsPersonProperties).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
od_app_user_id: 'usr_amr_42',
|
|
od_source_resolved: 'social',
|
|
od_source_resolution: 'onboarding',
|
|
od_onboarding_at: '2026-07-01T07:00:00.000Z',
|
|
od_amr_entry_id: expect.stringMatching(/^od-amr-/u),
|
|
od_amr_entry_source: 'inline_model_switcher_amr_row',
|
|
od_amr_entry_at: '2026-07-02T08:15:00.000Z',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not emit an empty bind when the signed-in user id is missing', () => {
|
|
bindSignedInUserAttributionPersonProperties(null);
|
|
|
|
expect(setAnalyticsPersonProperties).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function writeLandingAttributionCookie(value: Record<string, unknown>): void {
|
|
document.cookie = `od_attr=${encodeURIComponent(JSON.stringify(value))};path=/`;
|
|
}
|