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
9 changes: 9 additions & 0 deletions migrations/20260608000000_add_onboarded_at_to_users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
exports.up = (knex) =>
knex.schema.alterTable('users', (t) => {
t.timestamp('onboarded_at', { useTz: true }).nullable().defaultTo(null);
});

exports.down = (knex) =>
knex.schema.alterTable('users', (t) => {
t.dropColumn('onboarded_at');
});
1 change: 1 addition & 0 deletions src/controllers/StripeController/StripeController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function buildUser(overrides: Partial<UserWithOwner> = {}): UserWithOwner {
theme: null,
anki_web_acknowledged_at: null,
upload_primer_dismissed_at: null,
onboarded_at: null,
email_verified: false,
ai_template_generate_count: 0,
ai_template_modify_count: 0,
Expand Down
12 changes: 12 additions & 0 deletions src/controllers/UsersControllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ class UsersController {
trial_started_at: user?.trial_started_at ?? null,
signup_country: signupCountry,
chat_consent_at: user?.chat_consent_at ?? null,
created_at: user?.created_at ?? null,
onboarded_at: user?.onboarded_at ?? null,
},
locals,
linked_email: linkedEmail,
Expand Down Expand Up @@ -773,6 +775,16 @@ class UsersController {
}
}

async markOnboarded(_req: express.Request, res: express.Response) {
const { owner } = res.locals;
if (owner == null) {
return res.status(401).json({ message: 'Authentication required' });
}
const repository = new UsersRepository(this.db);
await repository.markOnboarded(owner);
return res.status(204).end();
}

}

export default UsersController;
7 changes: 7 additions & 0 deletions src/data_layer/UsersRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,13 @@ class UsersRepository {
}));
}

markOnboarded(id: string | number) {
return this.database(this.table)
.where({ id })
.whereNull('onboarded_at')
.update({ onboarded_at: this.database.fn.now() });
}

incrementCardUsage(id: string | number, cardCount: number) {
if (cardCount <= 0) return Promise.resolve(0);
return this.database(this.table)
Expand Down
6 changes: 6 additions & 0 deletions src/data_layer/public/Users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export default interface Users {
stripe_customer_id: string | null;

upload_primer_dismissed_at: Date | null;

onboarded_at: Date | null;
}

/** Represents the initializer for the table public.users */
Expand Down Expand Up @@ -118,6 +120,8 @@ export interface UsersInitializer {
stripe_customer_id?: string | null;

upload_primer_dismissed_at?: Date | null;

onboarded_at?: Date | null;
}

/** Represents the mutator for the table public.users */
Expand Down Expand Up @@ -171,4 +175,6 @@ export interface UsersMutator {
stripe_customer_id?: string | null;

upload_primer_dismissed_at?: Date | null;

onboarded_at?: Date | null;
}
6 changes: 6 additions & 0 deletions src/routes/UserRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,12 @@ const UserRouter = () => {
(req, res) => userPreferencesController.deleteCardOptions(req, res)
);

router.patch(
'/api/users/me/onboarded',
RequireAuthentication,
(req, res) => controller.markOnboarded(req, res)
);

return router;
};

Expand Down
12 changes: 10 additions & 2 deletions web/src/lib/backend/getUserLocals.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Users from '../../schemas/public/Users';
import { get } from './api';
import { get2ankiApi } from './get2ankiApi';
import Users from '../../schemas/public/Users';

interface GetUserLocalsResponse {
locals: {
Expand All @@ -16,7 +16,15 @@ interface GetUserLocalsResponse {
passKind?: '24h' | '7d' | null;
};
linked_email: string;
user?: Users & { ankify_welcome_seen?: boolean; trial_started_at?: string | null; email_verified?: boolean; signup_country?: string | null; chat_consent_at?: string | null };
user?: Users & {
ankify_welcome_seen?: boolean;
trial_started_at?: string | null;
email_verified?: boolean;
signup_country?: string | null;
chat_consent_at?: string | null;
created_at?: string | null;
onboarded_at?: string | null;
};
features?: {
kiUI: boolean;
ops?: boolean;
Expand Down
10 changes: 10 additions & 0 deletions web/src/lib/backend/markOnboarded.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export async function markOnboarded(): Promise<void> {
try {
await fetch('/api/users/me/onboarded', {
method: 'PATCH',
credentials: 'include',
});
} catch {
// silent — the tour is already hidden; the server will receive this on next visit
}
}
43 changes: 28 additions & 15 deletions web/src/pages/UploadPage/UploadPage.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom';
import {
useQuery as useReactQuery,
useQueryClient,
useQuery as useReactQuery,
} from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom';
import { ErrorHandlerType } from '../../components/errors/helpers/getErrorMessage';
import useQuery from '../../lib/hooks/useQuery';
import { getVisibleText } from '../../lib/text/getVisibleText';
import {
dismissUploadPrimer,
fetchUserPreferences,
type ServerUserPreferences,
} from '../../lib/data_layer/userPreferencesSync';
import useQuery from '../../lib/hooks/useQuery';
import { useUserLocals } from '../../lib/hooks/useUserLocals';
import { getVisibleText } from '../../lib/text/getVisibleText';
import styles from '../../styles/shared.module.css';
import { OnboardingTour } from './components/OnboardingTour/OnboardingTour';
import UploadForm from './components/UploadForm/UploadForm';
import pageStyles from './UploadPage.module.css';

Expand Down Expand Up @@ -84,6 +86,7 @@ export function UploadPage({ setErrorMessage }: Readonly<Props>) {
const stored = globalThis.sessionStorage?.getItem(REATTACH_KEY) ?? null;
return stored != null && stored.length > 0 ? stored : null;
});
const { data: userLocals } = useUserLocals();

const prefsQuery = useReactQuery({
queryKey: ['user-preferences'],
Expand All @@ -95,7 +98,8 @@ export function UploadPage({ setErrorMessage }: Readonly<Props>) {
// Server is the only source of truth. Until the query resolves, hide the primer rather
// than flash it — a brief delay before showing orientation copy is better than briefly
// showing it to someone who has already dismissed it.
const primerVisible = prefsQuery.isFetched && prefsQuery.data?.uploadPrimerDismissedAt == null;
const primerVisible =
prefsQuery.isFetched && prefsQuery.data?.uploadPrimerDismissedAt == null;

useEffect(() => {
if (searchParams.get('from') === 'pass') {
Expand All @@ -108,12 +112,15 @@ export function UploadPage({ setErrorMessage }: Readonly<Props>) {

const handleDismissPrimer = () => {
const now = new Date().toISOString();
queryClient.setQueryData<ServerUserPreferences | null>(['user-preferences'], (current) => ({
cardOptions: current?.cardOptions ?? null,
theme: current?.theme ?? null,
ankiWebAcknowledgedAt: current?.ankiWebAcknowledgedAt ?? null,
uploadPrimerDismissedAt: now,
}));
queryClient.setQueryData<ServerUserPreferences | null>(
['user-preferences'],
(current) => ({
cardOptions: current?.cardOptions ?? null,
theme: current?.theme ?? null,
ankiWebAcknowledgedAt: current?.ankiWebAcknowledgedAt ?? null,
uploadPrimerDismissedAt: now,
})
);
void dismissUploadPrimer();
};

Expand All @@ -133,6 +140,10 @@ export function UploadPage({ setErrorMessage }: Readonly<Props>) {
Turn your notes into flashcards in seconds
</p>
</header>
<OnboardingTour
createdAt={userLocals?.user?.created_at ?? null}
onboardedAt={userLocals?.user?.onboarded_at ?? null}
/>
{reattachFilename != null && (
<div className={pageStyles.reattachBanner} role="status">
<span>Re-attach </span>
Expand All @@ -150,11 +161,13 @@ export function UploadPage({ setErrorMessage }: Readonly<Props>) {
>
</button>
<p className={pageStyles.primerHeading}>Make cards from your Notion toggles</p>
<p className={pageStyles.primerHeading}>
Make cards from your Notion toggles
</p>
<p className={pageStyles.primerBody}>
Each toggle becomes one card — the toggle title is the front, what's
inside is the back. Export your page from Notion as HTML and drop the
.zip below.
inside is the back. Export your page from Notion as HTML and drop
the .zip below.
</p>
<a
href="/documentation/start-here/upload-a-file"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
.tour {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 10px;
padding: 20px 24px;
margin-bottom: 20px;
max-width: 520px;
}

.progress {
display: flex;
gap: 6px;
margin-bottom: 14px;
}

.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-border, #e5e7eb);
}

.dotActive {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #3b82f6);
}

.title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #111827);
margin: 0 0 6px;
}

.hint {
font-size: 0.875rem;
color: var(--color-text-muted, #6b7280);
margin: 0 0 18px;
}

.controls {
display: flex;
gap: 8px;
align-items: center;
}

.btnPrimary {
padding: 6px 16px;
background: var(--color-primary, #3b82f6);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}

.btnPrimary:hover {
background: #2563eb;
}

.btnSecondary {
padding: 6px 16px;
background: transparent;
color: var(--color-text, #374151);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}

.btnSecondary:hover {
background: var(--color-surface-hover, #f9fafb);
}

.btnSkip {
padding: 6px 12px;
background: transparent;
color: var(--color-text-muted, #6b7280);
border: none;
font-size: 0.875rem;
cursor: pointer;
margin-left: auto;
}

.btnSkip:hover {
color: var(--color-text, #374151);
}
Loading