From 447884d34dc7c463fdba8a988728df78fed6b081 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Tue, 19 May 2026 16:06:45 +0200 Subject: [PATCH 1/4] feat: start trial atomically on registration with start_trial flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When /api/users/register receives start_trial=1, it calls StartTrialUseCase after account creation. If the trial fails, the failure is logged but the signup still succeeds — account creation is the more valuable side effect. Co-Authored-By: Claude Sonnet 4.6 --- src/controllers/UsersControllers.test.ts | 73 ++++++++++++++++++++++++ src/controllers/UsersControllers.ts | 9 +++ 2 files changed, 82 insertions(+) diff --git a/src/controllers/UsersControllers.test.ts b/src/controllers/UsersControllers.test.ts index 06045b9f9..8a1eaf142 100644 --- a/src/controllers/UsersControllers.test.ts +++ b/src/controllers/UsersControllers.test.ts @@ -23,6 +23,19 @@ jest.mock('../lib/misc/hashToken', () => ({ default: jest.fn().mockReturnValue('hashed-token'), })); +const mockMarkTrialStarted = jest.fn().mockResolvedValue(undefined); +const mockGetById = jest.fn().mockResolvedValue({ patreon: false, trial_started_at: null }); + +jest.mock('../data_layer/UsersRepository', () => { + return jest.fn().mockImplementation(() => ({ + setSignupCountryIfMissing: jest.fn().mockResolvedValue(undefined), + getSignupCountry: jest.fn().mockResolvedValue(null), + markTrialStarted: mockMarkTrialStarted, + getById: mockGetById, + getCardUsage: jest.fn().mockResolvedValue({ cards_used: 0 }), + })); +}); + import UsersController from './UsersControllers'; import UsersService, { MagicLinkRateLimitError } from '../services/UsersService'; import AuthenticationService from '../services/AuthenticationService'; @@ -197,6 +210,66 @@ describe('UsersController.register', () => { ); }); + it('starts the trial after registration when start_trial flag is set', async () => { + const register = jest.fn().mockResolvedValue([{ id: 1 }]); + const newJWTToken = jest.fn().mockResolvedValue('jwt-trial-tok'); + const persistToken = jest.fn().mockResolvedValue(undefined); + const updateLastLoginAt = jest.fn().mockResolvedValue(undefined); + mockMarkTrialStarted.mockClear(); + mockGetById.mockResolvedValue({ patreon: false, trial_started_at: null }); + + const { controller } = buildController({ register, newJWTToken, persistToken, updateLastLoginAt }); + const req = { + body: { email: 'trial@example.com', password: SAMPLE_PW, start_trial: '1' }, + query: {}, + } as unknown as express.Request; + const res = buildRes(); + const next = jest.fn(); + + await controller.register(req, res, next); + + expect(res.cookie).toHaveBeenCalledWith('token', 'jwt-trial-tok'); + expect(mockMarkTrialStarted).toHaveBeenCalledTimes(1); + }); + + it('does not fail registration when start_trial is set but trial already used', async () => { + const register = jest.fn().mockResolvedValue([{ id: 1 }]); + const newJWTToken = jest.fn().mockResolvedValue('jwt-tok2'); + mockMarkTrialStarted.mockClear(); + mockGetById.mockResolvedValue({ patreon: false, trial_started_at: new Date() }); + + const { controller } = buildController({ register, newJWTToken }); + const req = { + body: { email: 'existing@example.com', password: SAMPLE_PW, start_trial: '1' }, + query: {}, + } as unknown as express.Request; + const res = buildRes(); + const next = jest.fn(); + + await controller.register(req, res, next); + + expect(res.status).toHaveBeenCalledWith(200); + expect(mockMarkTrialStarted).not.toHaveBeenCalled(); + }); + + it('does not start trial when start_trial flag is absent', async () => { + const register = jest.fn().mockResolvedValue([{ id: 1 }]); + mockMarkTrialStarted.mockClear(); + + const { controller } = buildController({ register }); + const req = { + body: { email: 'notrial@example.com', password: SAMPLE_PW }, + query: {}, + } as unknown as express.Request; + const res = buildRes(); + const next = jest.fn(); + + await controller.register(req, res, next); + + expect(res.status).toHaveBeenCalledWith(200); + expect(mockMarkTrialStarted).not.toHaveBeenCalled(); + }); + it('returns 400 when the email is already registered', async () => { const getUserFrom = jest .fn() diff --git a/src/controllers/UsersControllers.ts b/src/controllers/UsersControllers.ts index 1dbde23a1..d6feab711 100644 --- a/src/controllers/UsersControllers.ts +++ b/src/controllers/UsersControllers.ts @@ -187,6 +187,15 @@ class UsersController { } catch { // country capture is best-effort } + if (req.body.start_trial === '1' || req.body.start_trial === true) { + try { + const trialRepo = new UsersRepository(this.db); + const trialUseCase = new StartTrialUseCase(trialRepo); + await trialUseCase.execute(newUser.id); + } catch (trialError) { + console.info('Trial start failed after registration', trialError); + } + } const token = await this.authService.newJWTToken(newUser.id); if (token) { await this.authService.persistToken(token, newUser.id.toString()); From 8d357867fd6ae297633cc66bf702566633591b49 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Tue, 19 May 2026 16:06:50 +0200 Subject: [PATCH 2/4] feat: forward start_trial param through RegisterForm to backend RegisterPage reads start_trial=1 from query params and passes it to RegisterForm as a prop. RegisterForm forwards the flag to the backend register call and shows "Create account and start trial" as the submit button label when the flag is set. Co-Authored-By: Claude Sonnet 4.6 --- web/src/components/forms/RegisterForm.tsx | 13 ++++++++++--- web/src/lib/backend/Backend.ts | 6 +++++- web/src/pages/RegisterPage/RegisterPage.tsx | 3 ++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/web/src/components/forms/RegisterForm.tsx b/web/src/components/forms/RegisterForm.tsx index 3ef93580e..baad3f313 100644 --- a/web/src/components/forms/RegisterForm.tsx +++ b/web/src/components/forms/RegisterForm.tsx @@ -11,11 +11,18 @@ import styles from '../../styles/auth.module.css'; interface Props { readonly setErrorMessage: ErrorHandlerType; readonly redirect?: string | null; + readonly startTrial?: boolean; } const MIN_PASSWORD_LENGTH = 8; -function RegisterForm({ setErrorMessage, redirect }: Props) { +function submitLabel(loading: boolean, startTrial: boolean | undefined): string { + if (loading) return 'Creating account…'; + if (startTrial) return 'Create account and start trial'; + return 'Create account'; +} + +function RegisterForm({ setErrorMessage, redirect, startTrial }: Props) { const [email, setEmail] = useState(localStorage.getItem('email') || ''); const [tos, setTos] = useState(false); const [password, setPassword] = useState(''); @@ -44,7 +51,7 @@ function RegisterForm({ setErrorMessage, redirect }: Props) { setLoading(true); try { - const res = await get2ankiApi().register('', email, password, signupOrigin); + const res = await get2ankiApi().register('', email, password, signupOrigin, startTrial); if (res.status === 200) { globalThis.sessionStorage?.setItem('email_verification_pending', 'true'); globalThis.location.href = redirect ? `/${redirect.replace(/^\//, '')}` : '/'; @@ -174,7 +181,7 @@ function RegisterForm({ setErrorMessage, redirect }: Props) { className={styles.submitButton} disabled={!isValid() || loading} > - {loading ? 'Creating account…' : 'Create account'} + {submitLabel(loading, startTrial)} diff --git a/web/src/lib/backend/Backend.ts b/web/src/lib/backend/Backend.ts index 33134c525..201fa5bcb 100644 --- a/web/src/lib/backend/Backend.ts +++ b/web/src/lib/backend/Backend.ts @@ -380,13 +380,17 @@ export class Backend { name: string, email: string, password: string, - source?: string | null + source?: string | null, + startTrial?: boolean ): Promise { const endpoint = `${this.baseURL}users/register`; const payload: Record = { name, email, password }; if (source != null && source.length > 0) { payload.source = source; } + if (startTrial === true) { + payload.start_trial = '1'; + } return post(endpoint, payload); } diff --git a/web/src/pages/RegisterPage/RegisterPage.tsx b/web/src/pages/RegisterPage/RegisterPage.tsx index 059fffaf7..7a99c6856 100644 --- a/web/src/pages/RegisterPage/RegisterPage.tsx +++ b/web/src/pages/RegisterPage/RegisterPage.tsx @@ -10,10 +10,11 @@ interface Props { export function RegisterPage({ setErrorMessage }: Readonly) { const [searchParams] = useSearchParams(); const redirect = searchParams.get('redirect'); + const startTrial = searchParams.get('start_trial') === '1'; return ( - + ); } From 2cf964ade36a7413ead7470f28961dcebc4ac524 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Tue, 19 May 2026 16:06:57 +0200 Subject: [PATCH 3/4] feat: single CTA on anonymous limit screen and reattach banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anonymous users at the upload limit now see one primary CTA — "Create account and start trial" — linking to /register?redirect=/upload&start_trial=1. The filename is saved to sessionStorage so UploadPage can show a "Re-attach to convert" banner after registration. The sessionStorage entry is cleared on the next successful upload. Co-Authored-By: Claude Sonnet 4.6 --- .../pages/UploadPage/UploadPage.module.css | 18 +++++ web/src/pages/UploadPage/UploadPage.test.tsx | 49 ++++++++++++ web/src/pages/UploadPage/UploadPage.tsx | 13 ++++ .../components/UploadForm/UploadForm.test.tsx | 12 ++- .../components/UploadForm/UploadForm.tsx | 75 ++++++++++++------- 5 files changed, 135 insertions(+), 32 deletions(-) diff --git a/web/src/pages/UploadPage/UploadPage.module.css b/web/src/pages/UploadPage/UploadPage.module.css index c3058ec24..c41ad3b76 100644 --- a/web/src/pages/UploadPage/UploadPage.module.css +++ b/web/src/pages/UploadPage/UploadPage.module.css @@ -1,3 +1,21 @@ +/* ── Re-attach banner (shown after trial-on-register redirect) ── */ + +.reattachBanner { + max-width: 800px; + margin: 0 0 1rem; + padding: 0.625rem 1rem; + background: var(--color-bg-secondary); + border-left: 3px solid var(--color-text-link); + border-radius: var(--radius-md); + font-size: var(--text-sm); + color: var(--color-text-secondary); +} + +.reattachBanner strong { + font-weight: var(--font-medium); + color: var(--color-text-primary); +} + /* ── Toggle-model primer ── */ .primer { diff --git a/web/src/pages/UploadPage/UploadPage.test.tsx b/web/src/pages/UploadPage/UploadPage.test.tsx index 1db50cb94..7bca0a43a 100644 --- a/web/src/pages/UploadPage/UploadPage.test.tsx +++ b/web/src/pages/UploadPage/UploadPage.test.tsx @@ -36,6 +36,55 @@ const renderPage = () => { ); }; +const renderPageWithSession = (sessionKey: string, sessionValue: string | null) => { + if (sessionValue != null) { + globalThis.sessionStorage.setItem(sessionKey, sessionValue); + } else { + globalThis.sessionStorage.removeItem(sessionKey); + } + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + {}} />} + /> + + + + ); +}; + +describe('UploadPage reattach banner', () => { + beforeEach(() => { + fetchUserPreferences.mockResolvedValue({ + cardOptions: null, + theme: null, + ankiWebAcknowledgedAt: null, + uploadPrimerDismissedAt: '2026-01-01', + }); + }); + + it('shows the reattach banner when upload_pending_filename is set in sessionStorage', async () => { + renderPageWithSession('upload_pending_filename', 'biochemistry.zip'); + expect(await screen.findByRole('status')).toBeInTheDocument(); + expect(screen.getByRole('status').textContent).toContain('Re-attach'); + expect(screen.getByRole('status').textContent).toContain('biochemistry.zip'); + expect(screen.getByRole('status').textContent).toContain('to convert'); + globalThis.sessionStorage.removeItem('upload_pending_filename'); + }); + + it('does not show the reattach banner when upload_pending_filename is absent', async () => { + renderPageWithSession('upload_pending_filename', null); + await waitFor(() => expect(fetchUserPreferences).toHaveBeenCalled()); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); +}); + describe('UploadPage primer', () => { beforeEach(() => { dismissUploadPrimer.mockClear(); diff --git a/web/src/pages/UploadPage/UploadPage.tsx b/web/src/pages/UploadPage/UploadPage.tsx index a3510af51..f0dea97f4 100644 --- a/web/src/pages/UploadPage/UploadPage.tsx +++ b/web/src/pages/UploadPage/UploadPage.tsx @@ -16,6 +16,8 @@ import styles from '../../styles/shared.module.css'; import UploadForm from './components/UploadForm/UploadForm'; import pageStyles from './UploadPage.module.css'; +const REATTACH_KEY = 'upload_pending_filename'; + const WALKTHROUGHS: ReadonlyArray<[string, string]> = [ ['UnTo_fN1jpc', 'How I use Notion to Anki as a medical student'], ['JrYdp18Hbs8', 'Notion to Anki — complete guide'], @@ -78,6 +80,10 @@ export function UploadPage({ setErrorMessage }: Readonly) { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); + const [reattachFilename, setReattachFilename] = useState(() => { + const stored = globalThis.sessionStorage?.getItem(REATTACH_KEY) ?? null; + return stored != null && stored.length > 0 ? stored : null; + }); const prefsQuery = useReactQuery({ queryKey: ['user-preferences'], @@ -127,6 +133,13 @@ export function UploadPage({ setErrorMessage }: Readonly) { Turn your notes into flashcards in seconds

+ {reattachFilename != null && ( +
+ Re-attach + {reattachFilename} + to convert +
+ )} {primerVisible && (
- )} - {showSignInPrompt && ( + {showSignInPrompt ? ( saveFilenameForReattach(limitInfo?.filename ?? null)} > - Sign in to start trial + Create account and start trial + ) : ( + <> + + See upgrade options + + {showTrialButton && ( + + )} + )}