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
14 changes: 14 additions & 0 deletions content/updates/1.14.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
version: 1.14.3
date: 2026-10-23
title: Straight answers about Tutafy, and a clearer name in search
summary: The home page now answers what Tutafy is, who it is for and how it differs from a marketplace, and every page title and share card carries the Tutafy name.
image: /updates/home-answers.webp
imageAlt: The home page questions section with three new answers open: what Tutafy is, who it is for and how it differs from Preply or iTalki
---
- The home page questions start with three plain answers: what Tutafy is, who it is for, and how it differs from a marketplace such as Preply or iTalki.
- The About page opens with the same short answer before the story.
- The tutor directory explains how booking works, who sets the price and how to list yourself, once tutors are listed.
- Every page title now ends with "| Tutafy", and a link shared on social media or in a chat shows a Tutafy card with the name and what the product does.
- The AI tools count on the public pages is now 7, the number of tools in the app (it said 10).
- "Find tutors" is in the footer of every public page.
3 changes: 2 additions & 1 deletion docs/feature-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ Tutafy: booking, lessons and payments for private tutors (Next.js 16, Supabase,
- A home-made measure must not claim more than it checks (T-0641): `tests/support/measure-claims.ts` is the table of every kind a measure says it catches (`scripts/mobile-audit.mjs`, the source scans in `tests/support/mobile-scans.ts`, the rate-limit check): one sample in the broken state and one in the right state per kind. `tests/measure-claims.test.ts` (in `npm test`) runs the REAL measure on both (the browser measures run on the HTML samples in a small fake page, `tests/support/mini-dom.ts`) and fails when a comment in those files claims a catch ("catches", "must stay", "no longer", "is reported" ...) without a `[claim: id]` marker of a row, or a row is not claimed. One marker cannot answer for a list: when a marked comment names a kind (summary, tab, menu item, checkbox, select, textarea, min-w-[Npx], style width, grid-cols, text-xs ...), a row it marks must list that kind in `covers` (`KIND_WORDS` in the same file holds the words per measure file; kept by hand), and a row can have more samples (`moreBroken`, `moreGood`). To add a measure: write the claim with `[claim: id]` in its comment, add one row per kind it names, and add the kind words. What the comments say but the table does not hold (the other measures of `audit()`, the scanners that have their own self-check in `tests/rate-limits.test.ts`) is listed with the reason in `LEFT_WITHOUT_ROW` of the same file; those comments only describe. Today the table has [count: CLAIMS=30] rows, the comments of [count: MEASURE_FILES=3] files are read, and [count: LEFT_WITHOUT_ROW=4] things are left without a row.

- Accessibility of the public pages (T-0638): `tests/accessibility.test.ts` ([count: ACCESSIBILITY_TESTS=12] tests) keeps pinch-zoom allowed, exactly one `<main>` on each of the [count: PUBLIC_PAGES=19] public pages (a layout above a page counts), and no pale grey text (`text-gray-200`, `-300`, `-400`; icons and disabled controls are listed with a reason in `GREY_EXEMPT`) in the [count: PUBLIC_TEXT_FILES=26] files of `PUBLIC_TEXT_FILES` and in every layout above a public page.
- Brand, titles and answers (T-0850): `tests/seo-brand.test.ts` keeps one title pattern ("Topic | Tutafy"; the home page and the two posts about Tutafy start with it, within 60 characters, `BLOG_SEO` adds the brand to a post title that lacks it), the share picture `src/app/og/route.tsx` (1200 x 630 PNG with the name; every page points to `/og`), the first three FAQ answers (what Tutafy is, who it is for, how it differs from a marketplace: home page, About, `/llms.txt` "In short"), the directory FAQ with its FAQPage data from one list (`TUTORS_FAQ`), the AI tool count the public pages state (the number in the app), the lesson price in a profile only when real (`lessonOffer`, never a rating of its own), and an alternative text on every public image.
- Public pages review (T-0843): `tests/seo-public-pages.test.ts` keeps the structured data of the index pages (`pageJsonLd` in `src/lib/seo.ts`: about, contact, customers, updates, blog and the tutor directory say what kind of page they are, with a breadcrumb and the items they list, never an e-mail address), the own title and noindex of the 404 page (`src/app/not-found.tsx`), and indigo-600 (not 500) for small labels and the buttons and bands that carry white text. The voice of the site is written down in `docs/brand-voice.md` (a draft for the owner to approve).

- Numbers that count a list in the code (T-0646): never type such a number bare in a report or in this map. Write it as a marker, the word count, a colon, the list name, an equals sign and the number inside square brackets, as in [count: NAME=N]. `tests/support/counted-lists.ts` maps each list name to a function that counts the real list (an exported array, the literal of a constant, the `test(` calls of a file); `tests/counted-lists.test.ts` (in `npm test`) turns red when a marker in `docs/` or `README.md` has a different number than the code, names a list the table does not have, or is written wrongly. Run `npm run counts` for the numbers now, to copy. The project's status file lives in the Helme Brain, outside this repository, so `npm test` does not read it (set `COUNTS_STATUS_FILE` to a path to have the test read it too); before a report is filed, check it with `npm run counts -- <path of the status file>` (exit code 1 on a wrong marker). To count a new list, add a row to `COUNTED_LISTS`. A list that a checkout does not have cannot be counted there, and a marker for it is reported. Today the table has [count: COUNT_LIST_TESTS=14] tests of its own and the repository has [count: TEST_FILES=94] test files.
- Numbers that count a list in the code (T-0646): never type such a number bare in a report or in this map. Write it as a marker, the word count, a colon, the list name, an equals sign and the number inside square brackets, as in [count: NAME=N]. `tests/support/counted-lists.ts` maps each list name to a function that counts the real list (an exported array, the literal of a constant, the `test(` calls of a file); `tests/counted-lists.test.ts` (in `npm test`) turns red when a marker in `docs/` or `README.md` has a different number than the code, names a list the table does not have, or is written wrongly. Run `npm run counts` for the numbers now, to copy. The project's status file lives in the Helme Brain, outside this repository, so `npm test` does not read it (set `COUNTS_STATUS_FILE` to a path to have the test read it too); before a report is filed, check it with `npm run counts -- <path of the status file>` (exit code 1 on a wrong marker). To count a new list, add a row to `COUNTED_LISTS`. A list that a checkout does not have cannot be counted there, and a marker for it is reported. Today the table has [count: COUNT_LIST_TESTS=14] tests of its own and the repository has [count: TEST_FILES=96] test files.

- A line citation must point inside its file (T-0684): a report that writes "`src/lib/x.ts` dòng 40-52" or "src/lib/x.ts:40-52" is checked by `tests/line-refs.test.ts` (helper `tests/support/line-refs.ts`, in `npm test`): the file must exist and N and M must be lines of it (1 <= N <= M <= its length); a bare file name or the end of a path ("settings/page.tsx") is wrong only when it is too short for every file it names. A list is read to its last number ("`file` dòng 64 và 99", "dòng 75, 88", "dòng 163 và 280-281", "path:10-12, 40"), and so is the number-first form ("dòng 75-93 của `file`", "lines 3-9 of file"); EVERY number of a list is taken as a line whatever word follows it ("dòng 10 và 83 kiểm" checks 83), with ONE exception on purpose: a number followed by a unit of counting or measuring (test, ký tự, chars, ms, giây, phút, giờ, ngày, tệp, files, lần, dòng, lines, bytes, trang, mục, người, buổi, % ... : the closed list `UNIT` in `tests/support/line-refs.ts`) counts something else, so "dòng 196, 45 ký tự" and "dòng 12 và 300 test khác" read only 196 and 12; a unit that is not on the list is read as a line. A bare number after a comma is always a line, so write "dòng 18-20, trả mã 401", not "dòng 18-20, 401". It does NOT read a line number with no file name in the same phrase ("dòng 80" alone, "(dòng 23-24)" when the file was named in an earlier sentence), "path#L40", or a path written with a backslash: those are not checked. It reads the Markdown files in the root and under `docs/`, always. The project's status file lives in the Helme Brain, outside this repository, so check it with `npm run line-refs -- <path of the status file>` (exit code 1 when a citation is past the end; a file that is on no branch of this clone is listed, not failed; a file longer on another branch than here is not a mistake, because the status file speaks about several branches at once), or set `LINE_REFS_STATUS_FILE` to have `npm test` read it too. Only the length is checked: a citation still inside the file but no longer on the lines it quotes is for a human to recheck, so open the file and count the lines before writing them. The pull request workflow `.github/workflows/pull-request.yml` runs `npm run typecheck`, `npm test` and `npm run line-refs` on every pull request.
- A merged pull request must not be written as waiting (T-0693): `tests/pr-status.test.ts` (helper `tests/support/pr-status.ts`, in `npm test`) finds every pull request number (`#9999`, `pull request 9999`, `PR #9999`, `/pull/9999`) that belongs to a waiting phrase: "chờ ... gộp" with up to five words between ("chờ gộp", "chờ chủ gộp", "chờ review rồi chủ gộp", and the list forms Helme writes: "Việc chờ W-2067 (gộp #9999)", "Việc chờ: W-2067 (gộp #9999)", "Việc chờ của chủ: W-1918 (gộp #9998)": a colon after "chờ", a colon, comma or bracket inside, "gộp" after a bracket), "chủ gộp" ("W-1844: chủ gộp pull request #1"; "chủ đã gộp" is not one), "chưa gộp", "chờ merge", "awaiting merge", "waiting to be merged" or "not yet merged". A number belongs to the phrase when it is on the same line within 160 characters, or, when the phrase's line has none, when it is the nearest number of the same list item (a "- T-xxxx" or "- W-xxxx" with its indented lines, at most 5 lines away; a heading, a new list item or a new paragraph ends the item). It asks GitHub, read-only (`gh pr view <n> --json state`), what it is: MERGED is a failure. A number with "đã gộp" or "is merged" written next to it is the text saying it is merged and is not asked, but only its OWN word counts: the word belongs to the nearest number on its line, so "PR #38 đã gộp, PR #39 chờ gộp" still asks about #39, and "đã gộp" anywhere in the item for the same number clears it. NOT caught: a waiting sentence in other words ("còn mở", "chưa vào master", "đợi duyệt"), "chờ" and "gộp" with a full stop, a semicolon or more than five words between them ("Việc chờ cho chủ: W-1909 (đọc kết quả rồi bấm Done; gộp #9997 ...)"), a phrase with no pull request number in its item or more than 5 lines away, a number written without "#", "PR" or "pull request" in front of it; when a merged pull request is mentioned only in the past ("trước đó chờ gộp"), write "đã gộp" next to its number. When GitHub cannot be reached (no gh, not logged in, no network) the numbers are listed as skipped and nothing fails. It reads the same Markdown files as the line-citation check, and asks GitHub only when one of them has such a phrase. The status file is checked with `npm run pr-status -- <path of the status file>` (exit code 1 on a merged pull request still waiting), or set `MERGED_PR_STATUS_FILE` to have `npm test` read it; when a pull request is merged, change its entry to say so, with the merge commit, instead of leaving the waiting words. The tests use a pretend gh (`PR_STATUS_GH_SCRIPT`), never the network. The pull request workflow runs it as the last step, with a token that can only read pull requests.
Expand Down
Binary file added public/updates/home-answers.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { pageMetadata, PAGE_SEO } from '@/lib/page-meta'
import { jsonLdScript, pageJsonLd } from '@/lib/seo'
import { SiteFooter } from '@/components/SiteFooter'
import { SiteLogo } from '@/components/SiteLogo'
import { FAQ } from '@/lib/site-content'

export const metadata = pageMetadata('about')

Expand Down Expand Up @@ -32,6 +33,8 @@ export default function AboutPage() {
Tutoring should pay the tutor — not the middleman.
</h1>

<p className="mt-5 text-[17px] text-gray-900 leading-relaxed font-medium">{FAQ[0].a}</p>

<div className="mt-8 space-y-5 text-[15px] text-gray-700 leading-relaxed">
<p>
Tutafy started with a frustration a lot of tutors know well. You&apos;re great at teaching — but the business
Expand Down
1 change: 1 addition & 0 deletions src/app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default function BlogPage() {
<div className="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<SiteLogo size="sm" />
<div className="flex gap-5">
<Link href="/tutors" className="text-[12px] text-gray-500 hover:text-gray-600">Find tutors</Link>
<Link href="/about" className="text-[12px] text-gray-500 hover:text-gray-600">About</Link>
<Link href="/updates" className="text-[12px] text-gray-500 hover:text-gray-600">Updates</Link>
<Link href="/contact" className="text-[12px] text-gray-500 hover:text-gray-600">Contact</Link>
Expand Down
33 changes: 33 additions & 0 deletions src/app/og/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ImageResponse } from 'next/og'

// The share picture of every page that has no picture of its own (T-0850): what shows when a link to Tutafy is pasted into a chat,
// a social post or an AI assistant. 1200 x 630, the size social networks ask for, with the name Tutafy first. Only text the site
// already says (src/lib/site-content.ts): no number, rating or promise that is not on the pages. The size and address are
// kept by tests/seo-brand.test.ts and used in src/lib/page-meta.ts.
const OG_WIDTH = 1200
const OG_HEIGHT = 630

export async function GET() {
return new ImageResponse(
(
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', background: '#4F46E5', color: '#FFFFFF', padding: '72px 80px' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ width: 88, height: 88, borderRadius: 24, background: '#FFFFFF', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="#4F46E5" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" />
<path d="M22 10v6" />
<path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" />
</svg>
</div>
<div style={{ marginLeft: 28, fontSize: 84, fontWeight: 800, letterSpacing: -2 }}>Tutafy</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 76, fontWeight: 800, lineHeight: 1.08, letterSpacing: -2, maxWidth: 980 }}>Run your whole tutoring business in one place.</div>
<div style={{ marginTop: 28, fontSize: 36, lineHeight: 1.3, color: '#E0E7FF', maxWidth: 980 }}>Bookings, video classroom, payments and AI lesson tools. 0% commission.</div>
</div>
<div style={{ display: 'flex', fontSize: 30, color: '#E0E7FF' }}>www.tutafy.com</div>
</div>
),
{ width: OG_WIDTH, height: OG_HEIGHT },
)
}
5 changes: 3 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export default async function LandingPage({ searchParams }: { searchParams: Prom
<div className="max-w-4xl mx-auto flex flex-wrap items-center justify-center gap-x-8 gap-y-2 text-[13px] text-gray-500">
<span className="flex items-center gap-1.5"><Check className="h-4 w-4 text-emerald-500" /> 0% commission</span>
<span className="flex items-center gap-1.5"><Check className="h-4 w-4 text-emerald-500" /> HD video included</span>
<span className="flex items-center gap-1.5"><Check className="h-4 w-4 text-emerald-500" /> 10 AI tools</span>
<span className="flex items-center gap-1.5"><Check className="h-4 w-4 text-emerald-500" /> 7 AI tools</span>
<span className="flex items-center gap-1.5"><Check className="h-4 w-4 text-emerald-500" /> Unlimited students on Pro</span>
</div>
</section>
Expand Down Expand Up @@ -249,7 +249,7 @@ export default async function LandingPage({ searchParams }: { searchParams: Prom
</div>
<div className="order-1 md:order-2">
<div className="inline-flex items-center gap-2 bg-amber-50 rounded-full px-3 py-1 mb-5">
<Sparkles className="h-3.5 w-3.5 text-amber-500" /><span className="text-[12px] font-semibold text-amber-700">10 AI teaching tools</span>
<Sparkles className="h-3.5 w-3.5 text-amber-500" /><span className="text-[12px] font-semibold text-amber-700">7 AI teaching tools</span>
</div>
<h2 className="text-[30px] font-extrabold text-gray-900 tracking-tight leading-tight">Get your evenings back</h2>
<p className="text-[15px] text-gray-500 mt-4 leading-relaxed">
Expand Down Expand Up @@ -431,6 +431,7 @@ export default async function LandingPage({ searchParams }: { searchParams: Prom
<SiteLogo size="sm" />
<p className="text-[12px] text-gray-500">© 2026 Tutafy. Built for tutors, by people who get it.</p>
<div className="flex flex-wrap justify-center gap-x-5 gap-y-2">
<Link href="/tutors" className="text-[12px] text-gray-500 hover:text-gray-600">Find tutors</Link>
<Link href="/about" className="text-[12px] text-gray-500 hover:text-gray-600">About</Link>
<Link href="/updates" className="text-[12px] text-gray-500 hover:text-gray-600">Updates</Link>
<Link href="/blog" className="text-[12px] text-gray-500 hover:text-gray-600">Blog</Link>
Expand Down
2 changes: 1 addition & 1 deletion src/app/switch/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const FEATURES = [

const PLANS = [
{ name: 'Free', price: '$0', per: '', tagline: 'Everything you need to start', features: ['Up to 10 students', 'Booking page', 'Built-in video classroom', 'Email & SMS reminders'], cta: 'Start free', href: '/register', highlight: false },
{ name: 'Pro', price: '$12', per: '/mo', tagline: 'For serious independent tutors', features: ['Unlimited students', 'All 10 AI teaching tools', 'Payments (PayPal / card)', 'Full analytics & reports'], cta: 'Go Pro', href: '/register', highlight: true, badge: 'Most popular' },
{ name: 'Pro', price: '$12', per: '/mo', tagline: 'For serious independent tutors', features: ['Unlimited students', 'All 7 AI teaching tools', 'Payments (PayPal / card)', 'Full analytics & reports'], cta: 'Go Pro', href: '/register', highlight: true, badge: 'Most popular' },
{ name: 'Academy', price: '$29', per: '/mo', tagline: 'For small teams & centers', features: ['Everything in Pro', 'Up to 5 tutors', 'Team payroll & split pay', 'Booking page per tutor'], cta: 'Start Academy', href: '/register', highlight: false },
]

Expand Down
2 changes: 2 additions & 0 deletions src/app/tutors/(directory)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Link from 'next/link'
import { SiteFooter } from '@/components/SiteFooter'
import { DirectoryView } from '@/components/directory/DirectoryView'
import { getDirectory } from '@/lib/directory-cache'
import { TutorsFaq } from '@/components/directory/TutorsFaq'
import { parseFilters, refreshCards } from '@/lib/directory'
import { SiteLogo } from '@/components/SiteLogo'
import { PAGE_SEO } from '@/lib/page-meta'
Expand Down Expand Up @@ -34,6 +35,7 @@ export default async function TutorsPage({ searchParams }: { searchParams: Promi
<main>
<DirectoryView cards={cards} listed={directory?.listed ?? 0} truncated={directory?.truncated ?? false}
initial={parseFilters(params)} nowIso={now.toISOString()} error={directory === null} />
{cards.length > 0 && <TutorsFaq />}
</main>

<SiteFooter />
Expand Down
Loading
Loading