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 && (