Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/controllers/UsersControllers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions src/controllers/UsersControllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
13 changes: 10 additions & 3 deletions web/src/components/forms/RegisterForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -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(/^\//, '')}` : '/';
Expand Down Expand Up @@ -174,7 +181,7 @@ function RegisterForm({ setErrorMessage, redirect }: Props) {
className={styles.submitButton}
disabled={!isValid() || loading}
>
{loading ? 'Creating account…' : 'Create account'}
{submitLabel(loading, startTrial)}
</button>
</div>
</form>
Expand Down
6 changes: 5 additions & 1 deletion web/src/lib/backend/Backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,13 +380,17 @@ export class Backend {
name: string,
email: string,
password: string,
source?: string | null
source?: string | null,
startTrial?: boolean
): Promise<Response> {
const endpoint = `${this.baseURL}users/register`;
const payload: Record<string, string> = { name, email, password };
if (source != null && source.length > 0) {
payload.source = source;
}
if (startTrial === true) {
payload.start_trial = '1';
}
return post(endpoint, payload);
}

Expand Down
3 changes: 2 additions & 1 deletion web/src/pages/RegisterPage/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ interface Props {
export function RegisterPage({ setErrorMessage }: Readonly<Props>) {
const [searchParams] = useSearchParams();
const redirect = searchParams.get('redirect');
const startTrial = searchParams.get('start_trial') === '1';

return (
<AuthPageBackground>
<RegisterForm setErrorMessage={setErrorMessage} redirect={redirect} />
<RegisterForm setErrorMessage={setErrorMessage} redirect={redirect} startTrial={startTrial} />
</AuthPageBackground>
);
}
18 changes: 18 additions & 0 deletions web/src/pages/UploadPage/UploadPage.module.css
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions web/src/pages/UploadPage/UploadPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,55 @@ const renderPage = () => {
);
};

const renderPageWithSession = (sessionKey: string, sessionValue: string | null) => {
if (sessionValue == null) {
globalThis.sessionStorage.removeItem(sessionKey);
} else {
globalThis.sessionStorage.setItem(sessionKey, sessionValue);
}
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/upload']}>
<Routes>
<Route
path="/upload"
element={<UploadPage setErrorMessage={() => {}} />}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
};

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();
Expand Down
13 changes: 13 additions & 0 deletions web/src/pages/UploadPage/UploadPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
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'],
Expand Down Expand Up @@ -78,6 +80,10 @@
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const [reattachFilename, setReattachFilename] = useState<string | null>(() => {

Check warning on line 83 in web/src/pages/UploadPage/UploadPage.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless assignment to variable "setReattachFilename".

See more on https://sonarcloud.io/project/issues?id=2anki_server&issues=AZ5AmQbZRkOwERAOU9vi&open=AZ5AmQbZRkOwERAOU9vi&pullRequest=2455
const stored = globalThis.sessionStorage?.getItem(REATTACH_KEY) ?? null;
return stored != null && stored.length > 0 ? stored : null;
});

const prefsQuery = useReactQuery({
queryKey: ['user-preferences'],
Expand Down Expand Up @@ -127,6 +133,13 @@
Turn your notes into flashcards in seconds
</p>
</header>
{reattachFilename != null && (
<div className={pageStyles.reattachBanner} role="status">

Check warning on line 137 in web/src/pages/UploadPage/UploadPage.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <output> instead of the "status" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=2anki_server&issues=AZ5AmQbZRkOwERAOU9vj&open=AZ5AmQbZRkOwERAOU9vj&pullRequest=2455
<span>Re-attach </span>
<strong>{reattachFilename}</strong>
<span> to convert</span>
</div>
)}
{primerVisible && (
<section className={pageStyles.primer} aria-label="How 2anki works">
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,7 @@ describe('limit state — start trial button', () => {
return container;
}

it('shows "Sign in to start trial" and hides "Start 1-hour trial" for anonymous users', async () => {
it('shows a single "Create account and start trial" CTA for anonymous users and no "Start 1-hour trial" button', async () => {
const container = renderWithLimitReached({
user: null,
locals: { owner: 0, patreon: false, subscriber: false, subscriptionInfo: { active: false, email: '', linked_email: '' } },
Expand All @@ -695,9 +695,13 @@ describe('limit state — start trial button', () => {
);
expect(trialBtn).toBeUndefined();

const signInLink = container.querySelector('a[href*="login"]');
expect(signInLink).not.toBeNull();
expect(signInLink?.textContent).toContain('Sign in to start trial');
const registerLink = container.querySelector('a[href*="register"]');
expect(registerLink).not.toBeNull();
expect(registerLink?.textContent).toContain('Create account and start trial');
expect(registerLink?.getAttribute('href')).toContain('start_trial=1');

const loginLink = container.querySelector('a[href*="login"]');
expect(loginLink).toBeNull();
});

it('renders error message and preserves form when startTrial returns already_used', async () => {
Expand Down
Loading