diff --git a/apps/web/harness/README.md b/apps/web/harness/README.md new file mode 100644 index 00000000000..53549ab5e2d --- /dev/null +++ b/apps/web/harness/README.md @@ -0,0 +1,28 @@ +# Onboarding team-step harness + +Renders `features/setup/flow/TeamStep` on its own — real component, real +Tailwind theme, no backend — so the screen can be driven and screenshotted +without a logged-in session. + +The hooks the step needs (user email, contacts, onboarding state, team +queries/mutations, analytics) are swapped for fixtures in `mocks/` via Vite +aliases; everything else is the real code. + +```bash +bunx vite -c harness/vite.config.ts # http://localhost:5199 +bun harness/shoot.mjs ./shots # drive it + write screenshots +``` + +Query params: + +- `?scenario=prefill` (default) — work-domain user with same-domain contacts, + so teammates arrive pre-added to the invite list +- `?scenario=plain` — personal-email user: no suggested domain, no prefill +- `?theme=dark` — Macro Dark instead of Macro Light + +`shoot.mjs` also asserts behavior as it goes (which rows are pre-added, what +the remove buttons drop, what the mutation and analytics event receive) and +exits non-zero on a page error, so it doubles as a smoke test. + +This directory sits outside `tsconfig.json`'s `include` and knip's `project` +globs on purpose — it is dev tooling, not shipped code. diff --git a/apps/web/harness/harness.css b/apps/web/harness/harness.css new file mode 100644 index 00000000000..4361ba60a92 --- /dev/null +++ b/apps/web/harness/harness.css @@ -0,0 +1,3 @@ +@import '../src/index.css'; + +@source './main.tsx'; diff --git a/apps/web/harness/index.html b/apps/web/harness/index.html new file mode 100644 index 00000000000..9fa6fa065cb --- /dev/null +++ b/apps/web/harness/index.html @@ -0,0 +1,20 @@ + + + + + + Team step harness + + + +
+ + + diff --git a/apps/web/harness/main.tsx b/apps/web/harness/main.tsx new file mode 100644 index 00000000000..0cf03c5552a --- /dev/null +++ b/apps/web/harness/main.tsx @@ -0,0 +1,63 @@ +import { render } from 'solid-js/web'; +import { NoiseBackground } from '../src/features/setup/flow/shared'; +import { TeamStep } from '../src/features/setup/flow/TeamStep'; +import { DEFAULT_THEMES } from '../src/features/theme/constants'; +import './harness.css'; + +/** Paints one of the app's real theme presets onto :root. */ +function applyTheme(id: string) { + const theme = DEFAULT_THEMES.find((t) => t.id === id); + if (!theme) throw new Error(`no theme ${id}`); + const root = document.documentElement; + for (const [key, v] of Object.entries( + theme.tokens as Record + )) { + root.style.setProperty(`--${key}l`, `${v.l}`); + root.style.setProperty(`--${key}c`, `${v.c}`); + root.style.setProperty(`--${key}h`, `${v.h}deg`); + } + const tokens = theme.tokens as Record; + root.dataset.themeLight = tokens.b0.l > tokens.c0.l ? 'true' : 'false'; +} + +/** + * Renders the onboarding team step on its own, with the flow's card chrome + * copied from OnboardingFlow, so it can be screenshotted without a backend. + * `?scenario=plain` swaps in a personal-email user; `?theme=dark` flips it. + */ +function Harness() { + const params = new URLSearchParams(window.location.search); + applyTheme(params.get('theme') === 'dark' ? 'Macro Dark' : 'Macro Light'); + + return ( +
+ +
+
+
+
+
+

+ Macro is meant for teams +

+

+ Macro is built to be used with others. Invite your team to + share docs, channels, and context from day one. +

+
+
+ console.log('[continue]')} + onSkip={() => console.log('[skip]')} + /> +
+
+
+
+
+
+ ); +} + +const root = document.getElementById('root'); +if (root) render(() => , root); diff --git a/apps/web/harness/mocks/analytics.ts b/apps/web/harness/mocks/analytics.ts new file mode 100644 index 00000000000..2ec759aceed --- /dev/null +++ b/apps/web/harness/mocks/analytics.ts @@ -0,0 +1,4 @@ +export const useAnalytics = () => ({ + track: (event: string, props?: unknown) => + console.log('[analytics]', event, props), +}); diff --git a/apps/web/harness/mocks/contacts.ts b/apps/web/harness/mocks/contacts.ts new file mode 100644 index 00000000000..a890bccb6fe --- /dev/null +++ b/apps/web/harness/mocks/contacts.ts @@ -0,0 +1,21 @@ +import { hasWorkDomain } from '../scenario'; + +const WORK_CONTACTS = [ + 'nikhil@macro.com', + 'priya@macro.com', + 'tom@macro.com', + 'sarah@macro.com', + 'diego@macro.com', + 'ade@macro.com', + 'ellen@macro.com', +]; + +// Personal-email users still have contacts — just none on a team domain. +const PERSONAL_CONTACTS = ['mom@aol.com', 'nikhil@macro.com']; + +export const useContacts = () => () => + (hasWorkDomain() ? WORK_CONTACTS : PERSONAL_CONTACTS).map((email) => ({ + id: email, + email, + name: email.split('@')[0], + })); diff --git a/apps/web/harness/mocks/invitations.ts b/apps/web/harness/mocks/invitations.ts new file mode 100644 index 00000000000..ca55e60f6b8 --- /dev/null +++ b/apps/web/harness/mocks/invitations.ts @@ -0,0 +1,6 @@ +export const useUserInvitesQuery = () => ({ data: { invites: [] } }); + +export const useJoinTeamMutation = () => ({ + isPending: false, + mutate: (v: unknown) => console.log('[join-team]', v), +}); diff --git a/apps/web/harness/mocks/onboarding.ts b/apps/web/harness/mocks/onboarding.ts new file mode 100644 index 00000000000..b85a6107ae7 --- /dev/null +++ b/apps/web/harness/mocks/onboarding.ts @@ -0,0 +1,7 @@ +import { hasWorkDomain } from '../scenario'; + +export const useOnboardingQuery = () => ({ + data: { + suggested_team_domain: hasWorkDomain() ? 'macro.com' : null, + }, +}); diff --git a/apps/web/harness/mocks/teams.ts b/apps/web/harness/mocks/teams.ts new file mode 100644 index 00000000000..c456d2fdb48 --- /dev/null +++ b/apps/web/harness/mocks/teams.ts @@ -0,0 +1,8 @@ +export const useUserTeamsQuery = () => ({ data: [] as { name: string }[] }); + +export const useCreateTeamWithInvitesMutation = () => ({ + isPending: false, + mutateAsync: async (body: unknown) => { + console.log('[create-team]', JSON.stringify(body)); + }, +}); diff --git a/apps/web/harness/mocks/user.ts b/apps/web/harness/mocks/user.ts new file mode 100644 index 00000000000..3afba5e6cde --- /dev/null +++ b/apps/web/harness/mocks/user.ts @@ -0,0 +1,5 @@ +import { hasWorkDomain } from '../scenario'; + +/** Mirrors the real hook: a hook returning an Accessor. */ +export const useEmail = () => () => + hasWorkDomain() ? 'jacob@macro.com' : 'jacob@gmail.com'; diff --git a/apps/web/harness/scenario.ts b/apps/web/harness/scenario.ts new file mode 100644 index 00000000000..dacf0291455 --- /dev/null +++ b/apps/web/harness/scenario.ts @@ -0,0 +1,6 @@ +/** Which fixture the page renders, read from `?scenario=`. */ +export const scenario = () => + new URLSearchParams(window.location.search).get('scenario') ?? 'prefill'; + +/** A work-domain user with same-domain contacts vs. a personal-email user. */ +export const hasWorkDomain = () => scenario() !== 'plain'; diff --git a/apps/web/harness/shoot.mjs b/apps/web/harness/shoot.mjs new file mode 100644 index 00000000000..fc9eae5b79e --- /dev/null +++ b/apps/web/harness/shoot.mjs @@ -0,0 +1,108 @@ +import { mkdirSync } from 'node:fs'; +import { chromium } from 'playwright'; + +const OUT = process.argv[2] ?? '/tmp/shots'; +const BASE = 'http://127.0.0.1:5199'; +mkdirSync(OUT, { recursive: true }); + +const browser = await chromium.launch({ + executablePath: '/opt/pw-browsers/chromium', +}); +const page = await browser.newPage({ + viewport: { width: 900, height: 1000 }, + deviceScaleFactor: 2, +}); +const errors = []; +page.on('pageerror', (e) => errors.push(String(e))); +page.on('console', (m) => { + if (m.type() === 'error') errors.push(m.text()); + else console.log(` console: ${m.text()}`); +}); + +const shot = (name) => page.screenshot({ path: `${OUT}/${name}.png` }); + +const open = async (query) => { + await page.goto(`${BASE}/${query}`, { waitUntil: 'networkidle' }); + await page.waitForSelector('#team-name'); + await page.waitForTimeout(400); +}; + +const inviteValues = () => + page.$$eval('input[id^="invite-"]', (els) => els.map((e) => e.value)); + +// 1. The new default: same-domain teammates already in the list. +await open('?scenario=prefill'); +console.log('prefilled rows:', await inviteValues()); +console.log('cta:', await page.textContent('button:has-text("Create team")')); +await shot('01-prefilled-light'); +console.log( + 'remove buttons:', + await page.$$eval('button[aria-label^="Don\'t invite"]', (e) => e.length), + 'of', + (await inviteValues()).length, + 'rows' +); +console.log('page scrollable:', await page.evaluate(() => document.body.scrollHeight > window.innerHeight)); + +// 2. Hover state on a remove button. +await page.hover('button[aria-label="Don\'t invite tom@macro.com"]'); +await page.waitForTimeout(200); +await shot('02-remove-hover'); + +// 3. Two teammates removed — the X actually drops the right rows. +await page.click('button[aria-label="Don\'t invite tom@macro.com"]'); +await page.click('button[aria-label="Don\'t invite ade@macro.com"]'); +await page.waitForTimeout(200); +console.log('after removing 2:', await inviteValues()); +console.log('cta:', await page.textContent('button:has-text("Create team")')); +await shot('03-after-removing-two'); + +// 4. Typing into the trailing empty row still works, and the row grows an X. +const slots = await page.$$('input[id^="invite-"]'); +await slots[slots.length - 1].fill('newhire@macro.com'); +await page.waitForTimeout(200); +console.log('after typing:', await inviteValues()); +await shot('04-typed-into-empty-row'); + +// 5. What actually gets submitted (logged by the mocked mutation). +const submitted = []; +page.on('console', (m) => { + if (m.text().startsWith('[create-team]')) submitted.push(m.text()); + if (m.text().startsWith('[analytics]')) submitted.push(m.text()); +}); +await page.click('button:has-text("Create team")'); +await page.waitForTimeout(500); +console.log('submitted:', submitted.join('\n ')); + +// 6. Dark theme. +await open('?scenario=prefill&theme=dark'); +await shot('05-prefilled-dark'); + +// 7. Personal-email user: no domain, no prefill, plain two-slot form. +await open('?scenario=plain'); +console.log('plain rows:', await inviteValues()); +console.log( + 'remove buttons on empty form:', + await page.$$eval('button[aria-label^="Remove"]', (e) => e.length), + '/ dont-invite buttons:', + await page.$$eval('button[aria-label^="Don\'t invite"]', (e) => e.length) +); +await shot('06-personal-email-plain'); + +// 8. Plain form, one address typed by hand — X appears only on that row. +await page.fill('#invite-0', 'someone@elsewhere.com'); +await page.waitForTimeout(200); +console.log( + 'after typing one:', + await page.$$eval('button[aria-label^="Don\'t invite"]', (e) => + e.map((b) => b.getAttribute('aria-label')) + ) +); +await shot('07-plain-typed-one'); + +await browser.close(); +if (errors.length) { + console.log('\nPAGE ERRORS:\n' + errors.join('\n')); + process.exit(1); +} +console.log('\nno page errors'); diff --git a/apps/web/harness/vite.config.ts b/apps/web/harness/vite.config.ts new file mode 100644 index 00000000000..249b7fd7d06 --- /dev/null +++ b/apps/web/harness/vite.config.ts @@ -0,0 +1,38 @@ +import { fileURLToPath } from 'node:url'; +import tailwind from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; +import solid from 'vite-plugin-solid'; +import solidSvg from 'vite-plugin-solid-svg'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +const r = (p: string) => fileURLToPath(new URL(p, import.meta.url)); + +const mock = (find: RegExp, file: string) => ({ + find, + replacement: r(`./mocks/${file}`), +}); + +export default defineConfig({ + root: r('.'), + plugins: [ + tailwind(), + tsconfigPaths({ root: r('..') }), + solid(), + solidSvg({ defaultAsComponent: true }), + ], + resolve: { + dedupe: ['solid-js'], + alias: [ + mock(/^@app\/lib\/analytics\/analytics-context$/, 'analytics.ts'), + mock(/^@core\/context\/user$/, 'user.ts'), + mock(/^@queries\/contacts\/contacts$/, 'contacts.ts'), + mock(/^@queries\/onboarding$/, 'onboarding.ts'), + mock(/^@queries\/team\/invitations$/, 'invitations.ts'), + mock(/^@queries\/team\/teams$/, 'teams.ts'), + ], + }, + server: { + port: 5199, + fs: { allow: [r('../../..')] }, + }, +}); diff --git a/apps/web/src/features/setup/flow/OnboardingFlow.tsx b/apps/web/src/features/setup/flow/OnboardingFlow.tsx index ab67e6fc949..baacaa35e68 100644 --- a/apps/web/src/features/setup/flow/OnboardingFlow.tsx +++ b/apps/web/src/features/setup/flow/OnboardingFlow.tsx @@ -185,7 +185,7 @@ function buildSteps( ...connectorSteps, { key: 'team', - title: 'Set up your team', + title: 'Macro is meant for teams', subtitle: 'Macro is built to be used with others. Invite your team to share docs, channels, and context from day one.', render: (controls) => ( diff --git a/apps/web/src/features/setup/flow/TeamStep.tsx b/apps/web/src/features/setup/flow/TeamStep.tsx index b67087b1ba3..09db5d3ddc7 100644 --- a/apps/web/src/features/setup/flow/TeamStep.tsx +++ b/apps/web/src/features/setup/flow/TeamStep.tsx @@ -3,6 +3,7 @@ import { useEmail } from '@core/context/user'; import { idToDisplayName } from '@core/user/util'; import CheckIcon from '@phosphor/check.svg'; import Plus from '@phosphor/plus.svg'; +import XIcon from '@phosphor/x.svg'; import { useContacts } from '@queries/contacts/contacts'; import { useOnboardingQuery } from '@queries/onboarding'; import { @@ -26,14 +27,20 @@ import { import { ContinueButton, deriveTeamName, - emailDomain, FormInput, - isPlausibleEmail, SkipButton, } from './shared'; +import { + INITIAL_INVITE_SLOTS, + prefillableTeammates, + removeInviteSlot, + validInviteEmails, + withPrefilledTeammates, +} from './teamInvites'; /** Set up your team: already a member → confirmation, pending invites → - * join, otherwise create (with domain-derived prefill + suggestions). */ + * join, otherwise create (with a domain-derived name and same-domain + * teammates pre-added to the invite list). */ export function TeamStep(props: { onContinue: () => void; onSkip: () => void; @@ -133,11 +140,8 @@ function InvitesPanel(props: { ); } -const INITIAL_INVITE_SLOTS = ['', '']; -const SUGGESTION_CAP = 6; - -/** Create a team: pre-derived name + same-domain invite suggestions when the - * user has a custom domain; the plain form otherwise. */ +/** Create a team: pre-derived name + same-domain teammates already sitting in + * the invite list (remove to opt them out); the plain form otherwise. */ function CreateTeamForm(props: { onContinue: () => void; onSkip: () => void }) { const analytics = useAnalytics(); const email = useEmail(); @@ -162,40 +166,44 @@ function CreateTeamForm(props: { onContinue: () => void; onSkip: () => void }) { ]); let inviteListEl: HTMLDivElement | undefined; - const validInvites = () => - [...new Set(inviteSlots().map((value) => value.trim()))].filter( - (value) => isPlausibleEmail(value) && value !== email() - ); + const validInvites = () => validInviteEmails(inviteSlots(), email()); - const suggestions = createMemo(() => { - const suffix = customDomain(); - if (!suffix) return []; - const own = email(); - const taken = new Set(inviteSlots().map((value) => value.trim())); - return contacts() - .filter( - (contact) => - contact.email !== own && - emailDomain(contact.email) === suffix && - !taken.has(contact.email) - ) - .slice(0, SUGGESTION_CAP); + // Same-domain teammates are pre-added rather than offered: the default is + // "invite them", and removing a row is how you opt one out. + const [prefilled, setPrefilled] = createSignal([]); + let hasPrefilled = false; + createEffect(() => { + if (hasPrefilled) return; + const teammates = prefillableTeammates({ + contacts: contacts(), + domain: customDomain(), + ownEmail: email(), + slots: inviteSlots(), + }); + // Contacts and the domain suggestion land asynchronously — keep waiting + // (and keep tracking) until there's actually someone to pre-add. + if (teammates.length === 0) return; + hasPrefilled = true; + setPrefilled(teammates); + setInviteSlots((slots) => withPrefilledTeammates(slots, teammates)); }); - const addInvite = (address: string) => { - setInviteSlots((slots) => { - const empty = slots.findIndex((value) => value.trim() === ''); - if (empty === -1) return [...slots, address]; - return slots.map((value, i) => (i === empty ? address : value)); - }); + /** Prefilled teammates the user took back out before submitting. */ + const removedPrefills = () => { + const kept = new Set(validInvites()); + return prefilled().filter((address) => !kept.has(address)).length; }; + // The X means "don't invite this person", so it belongs on rows that name + // one. Blank rows need no removing — they're dropped on submit anyway. + const canRemoveSlot = (value: string) => value.trim() !== ''; + const addEmptyInvite = () => { setInviteSlots((slots) => [...slots, '']); requestAnimationFrame(() => { - inviteListEl?.scrollTo({ - top: inviteListEl.scrollHeight, + inviteListEl?.lastElementChild?.scrollIntoView({ behavior: 'smooth', + block: 'nearest', }); }); }; @@ -204,6 +212,8 @@ function CreateTeamForm(props: { onContinue: () => void; onSkip: () => void }) { if (createTeam.isPending || name().trim().length === 0) return; // The mutation owns its toasts; stay put (form intact) on failure. const invitesSent = validInvites().length; + const invitesPrefilled = prefilled().length; + const invitesRemoved = removedPrefills(); try { await createTeam.mutateAsync({ name: name().trim(), @@ -215,6 +225,8 @@ function CreateTeamForm(props: { onContinue: () => void; onSkip: () => void }) { analytics.track('onboarding_v4_team', { action: 'created', invites_sent: invitesSent, + invites_prefilled: invitesPrefilled, + invites_removed: invitesRemoved, used_domain_suggestion: customDomain() !== undefined, }); props.onContinue(); @@ -222,36 +234,78 @@ function CreateTeamForm(props: { onContinue: () => void; onSkip: () => void }) { return (
- { - setNameTouched(true); - setName(value); - }} - /> + {/* Same row shape as an invite, with the remove gutter left empty, so + every input in the form shares one width. Labelled, because the + name arrives pre-filled — a placeholder alone would be invisible + exactly when the field needs explaining. */} +
+
+ + { + setNameTouched(true); + setName(value); + }} + /> +
+
+
+ + 0}> + {/* Say the quiet part out loud: these go out unless removed. */} +

+ Your teammates at {customDomain()} are ready to invite — remove anyone + you'd rather leave out. +

+
{/* Index, not For: slots are edited strings, and For keys by value — - each keystroke would recreate the input node and drop focus. */} -
(inviteListEl = el)} - class="flex max-h-48 flex-col gap-3 overflow-y-auto overscroll-contain" - > + each keystroke would recreate the input node and drop focus. + No inner scroller: the list opens pre-filled now, and a capped box + left a row sliced in half above the buttons — the flow's own + scroll container takes the height instead. */} +
(inviteListEl = el)} class="flex flex-col gap-3"> {(slot, i) => ( - - setInviteSlots((slots) => - slots.map((v, j) => (j === i ? value : v)) - ) - } - /> +
+
+ + setInviteSlots((slots) => + slots.map((v, j) => (j === i ? value : v)) + ) + } + /> +
+ {/* Gutter is always reserved, so a blank row's input still lines + up with the ones carrying a remove button. */} +
+ + + +
+
)}
@@ -266,29 +320,6 @@ function CreateTeamForm(props: { onContinue: () => void; onSkip: () => void }) { Add another teammate - 0}> -
-

- From your contacts at {customDomain()}: -

-
- - {(contact) => ( - - )} - -
-
-
- 0 diff --git a/apps/web/src/features/setup/flow/teamInvites.test.ts b/apps/web/src/features/setup/flow/teamInvites.test.ts new file mode 100644 index 00000000000..8721a4a34cc --- /dev/null +++ b/apps/web/src/features/setup/flow/teamInvites.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { + PREFILL_CAP, + prefillableTeammates, + removeInviteSlot, + validInviteEmails, + withPrefilledTeammates, +} from './teamInvites'; + +const contact = (email: string) => ({ email }); + +describe('prefillableTeammates', () => { + it('picks the contacts on the suggested domain', () => { + expect( + prefillableTeammates({ + contacts: [ + contact('ada@macro.com'), + contact('grace@other.com'), + contact('alan@macro.com'), + ], + domain: 'macro.com', + ownEmail: 'me@macro.com', + slots: ['', ''], + }) + ).toEqual(['ada@macro.com', 'alan@macro.com']); + }); + + it('never pre-adds the user themselves', () => { + expect( + prefillableTeammates({ + contacts: [contact('me@macro.com'), contact('ada@macro.com')], + domain: 'macro.com', + ownEmail: 'me@macro.com', + slots: [], + }) + ).toEqual(['ada@macro.com']); + }); + + it('skips addresses already in the list and duplicate contacts', () => { + expect( + prefillableTeammates({ + contacts: [ + contact('ada@macro.com'), + contact('ada@macro.com'), + contact('alan@macro.com'), + ], + domain: 'macro.com', + ownEmail: 'me@macro.com', + slots: [' ada@macro.com '], + }) + ).toEqual(['alan@macro.com']); + }); + + it('pre-adds nothing without a suggested domain', () => { + expect( + prefillableTeammates({ + contacts: [contact('ada@gmail.com')], + domain: undefined, + ownEmail: 'me@gmail.com', + slots: ['', ''], + }) + ).toEqual([]); + }); + + it('caps how many teammates get pre-added', () => { + const contacts = Array.from({ length: PREFILL_CAP + 3 }, (_, i) => + contact(`teammate${i}@macro.com`) + ); + expect( + prefillableTeammates({ + contacts, + domain: 'macro.com', + ownEmail: 'me@macro.com', + slots: [], + }) + ).toHaveLength(PREFILL_CAP); + }); +}); + +describe('withPrefilledTeammates', () => { + it('absorbs blank starter slots and leaves one row to type in', () => { + expect(withPrefilledTeammates(['', ''], ['ada@macro.com'])).toEqual([ + 'ada@macro.com', + '', + ]); + }); + + it('keeps anything already typed above the pre-added teammates', () => { + expect( + withPrefilledTeammates(['dev@macro.com', ''], ['ada@macro.com']) + ).toEqual(['dev@macro.com', 'ada@macro.com', '']); + }); +}); + +describe('removeInviteSlot', () => { + it('drops the row at the index', () => { + expect(removeInviteSlot(['a@macro.com', 'b@macro.com', ''], 1)).toEqual([ + 'a@macro.com', + '', + ]); + }); + + it('keeps one empty row when the last one goes', () => { + expect(removeInviteSlot(['a@macro.com'], 0)).toEqual(['']); + }); +}); + +describe('validInviteEmails', () => { + it('trims, dedupes, and drops blanks, junk, and the user themselves', () => { + expect( + validInviteEmails( + [' ada@macro.com ', 'ada@macro.com', '', 'nope', 'me@macro.com'], + 'me@macro.com' + ) + ).toEqual(['ada@macro.com']); + }); +}); diff --git a/apps/web/src/features/setup/flow/teamInvites.ts b/apps/web/src/features/setup/flow/teamInvites.ts new file mode 100644 index 00000000000..ef41715c412 --- /dev/null +++ b/apps/web/src/features/setup/flow/teamInvites.ts @@ -0,0 +1,68 @@ +import { emailDomain, isPlausibleEmail } from './shared'; + +/** The invite rows a fresh create-team form starts with. */ +export const INITIAL_INVITE_SLOTS = ['', '']; + +/** How many same-domain teammates we pre-add to the invite list. */ +export const PREFILL_CAP = 6; + +/** + * Same-domain teammates worth pre-adding to the invite list: the user's + * contacts on `domain`, minus themselves and anyone already listed, capped. + * + * Empty when the domain isn't team-worthy (the server decides that) or + * contacts haven't loaded — callers treat that as "nothing to prefill yet". + */ +export function prefillableTeammates(args: { + contacts: { email: string }[]; + domain: string | undefined; + ownEmail: string | undefined; + slots: string[]; +}): string[] { + const { contacts, domain, ownEmail, slots } = args; + if (!domain) return []; + const taken = new Set(slots.map((value) => value.trim())); + const seen = new Set(); + return contacts + .filter((contact) => { + if (contact.email === ownEmail) return false; + if (emailDomain(contact.email) !== domain) return false; + if (taken.has(contact.email) || seen.has(contact.email)) return false; + seen.add(contact.email); + return true; + }) + .map((contact) => contact.email) + .slice(0, PREFILL_CAP); +} + +/** + * The invite list after pre-adding `teammates`: anything already typed stays + * (in order), the teammates follow, and one empty row trails so there's + * always somewhere to type. Blank starter slots are absorbed. + */ +export function withPrefilledTeammates( + slots: string[], + teammates: string[] +): string[] { + const typed = slots.filter((value) => value.trim() !== ''); + return [...typed, ...teammates, '']; +} + +/** + * Drops row `index`, keeping at least one (empty) row so the form never loses + * its input. + */ +export function removeInviteSlot(slots: string[], index: number): string[] { + const next = slots.filter((_, i) => i !== index); + return next.length > 0 ? next : ['']; +} + +/** Deduped, plausible addresses that aren't the user's own. */ +export function validInviteEmails( + slots: string[], + ownEmail: string | undefined +): string[] { + return [...new Set(slots.map((value) => value.trim()))].filter( + (value) => isPlausibleEmail(value) && value !== ownEmail + ); +} diff --git a/apps/web/src/lib/analytics/app-events.ts b/apps/web/src/lib/analytics/app-events.ts index 94ad9e592ea..abb3355baa1 100644 --- a/apps/web/src/lib/analytics/app-events.ts +++ b/apps/web/src/lib/analytics/app-events.ts @@ -73,6 +73,10 @@ export type AppEvents = { onboarding_v4_team: { action: 'created' | 'joined_invite' | 'already_on_team'; invites_sent?: number; + /** Same-domain teammates the step pre-added to the invite list. */ + invites_prefilled?: number; + /** How many of those the user removed before creating the team. */ + invites_removed?: number; used_domain_suggestion?: boolean; }; /**