diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c68ba94..d4d147f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -156,10 +156,21 @@ jobs: cat /tmp/ssr.log >&2 || true exit 1 + # The whole suite, not one file. + # + # This ran only HomepageRenderTest.php, which left 43 of 222 tests + # skipping on every push: the `php` job above builds no bundles, so every + # `ssrHtml()` assertion short-circuits there, and this job — the one place + # they could execute — was scoped to a single file. All of + # LandingStructureTest, ProductHuntBadgeTest and TopBannerTest, plus the + # rendered halves of LandingSeoTest, FundingModelTest and + # LicenseFeaturesTest, were green-by-skip for their whole lives. + # + # REQUIRE_SSR turns a skip into a failure, so this cannot go quiet again. - name: SSR render tests env: REQUIRE_SSR: 1 - run: ./vendor/bin/pest tests/Feature/Landing/HomepageRenderTest.php + run: ./vendor/bin/pest - name: SSR service log if: always() diff --git a/CLAUDE.md b/CLAUDE.md index 80027e3..4ee0410 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ web middleware group in `bootstrap/app.php`. Consequences that bite: - `session()`, `->with('flash')` and `redirect()->back()->with(...)` do not work. - Inertia's `useForm().post()` must not be used. Writes go through plain `fetch` and hold their result in React state — copy the `useEmailForm` - pattern in `resources/js/components/landing/footer-cta.tsx`. + pattern in `resources/js/hooks/use-email-form.ts`. See `docs/architecture.md` before touching anything that posts data. diff --git a/app/Console/Commands/GenerateSitemapCommand.php b/app/Console/Commands/GenerateSitemapCommand.php index 1a547ff..9ed99cc 100644 --- a/app/Console/Commands/GenerateSitemapCommand.php +++ b/app/Console/Commands/GenerateSitemapCommand.php @@ -55,6 +55,18 @@ public function handle(): int ->setPriority(0.9) ->setLastModificationDate($this->sourceModifiedAt('resources/js/pages/Download.tsx')), ) + /* + * Weekly at 0.9, matching /download rather than the 0.8 monthly the + * database pages get. It is the other half of the same question — + * how do I get this — and it changes on the app's release cadence, + * not on a content edit here. + */ + ->add( + Url::create($baseUrl . '/ios') + ->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY) + ->setPriority(0.9) + ->setLastModificationDate($this->sourceModifiedAt('resources/js/pages/Ios.tsx')), + ) ->add( Url::create($baseUrl . '/privacy') ->setChangeFrequency(Url::CHANGE_FREQUENCY_MONTHLY) diff --git a/app/Http/Controllers/Landing/LandingController.php b/app/Http/Controllers/Landing/LandingController.php index ae6c5ce..e6d5e86 100644 --- a/app/Http/Controllers/Landing/LandingController.php +++ b/app/Http/Controllers/Landing/LandingController.php @@ -39,6 +39,23 @@ public function download(): Response ]); } + /** + * The iPhone and iPad app. + * + * Takes no App Store data of its own. Everything the page states about the + * iOS app is a literal in `resources/js/pages/Ios.tsx`, because there is no + * public App Store API this site could read without a credential, and this + * repository has none. `downloadUrls` is here only because `Header` + * requires it. + */ + public function ios(): Response + { + return Inertia::render('Ios', [ + 'downloadUrls' => $this->fetchDownloadUrls(), + 'githubStars' => $this->fetchGitHubStars(), + ]); + } + public function privacy(): Response { return Inertia::render('Privacy'); diff --git a/docs/architecture.md b/docs/architecture.md index deb8284..d92b91d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,7 @@ database and no credentials: every page is built from markdown in ``` / homepage -/download /faq product pages +/download /ios /faq product pages /privacy /terms /refund-policy /blog /blog/{slug} markdown in resources/blog /compare/{slug} data in resources/data/comparisons.json @@ -21,10 +21,9 @@ enough — Inertia is configured without an explicit `resolve`. ## What it does not serve -Buying a licence, signing in to an account, subscribing to the newsletter and -joining the beta are handled by the TablePro backend, which is a separate -application and not part of this repository. Requests to those paths never -reach this code. +Buying a licence, signing in to an account and subscribing to the newsletter are +handled by the TablePro backend, which is a separate application and not part of +this repository. Requests to those paths never reach this code. That is why this app runs **without a session**: it has nothing to keep state for. `StartSession`, `PreventRequestForgery`, `ShareErrorsFromSession` and @@ -36,7 +35,8 @@ Two consequences worth knowing before you write code here: - `csrf_token()` throws, and there is no CSRF meta tag. Nothing should add one. - `session()`, `redirect()->back()->with(...)` and Inertia's `useForm().post()` do not work. Forms use plain `fetch` and keep their result in React state — - see `useEmailForm` in `resources/js/components/landing/footer-cta.tsx`. + see `useEmailForm` in `resources/js/hooks/use-email-form.ts`, whose + remaining caller is the footer newsletter. ## The endpoints the pages call @@ -47,12 +47,17 @@ All anonymous, all rate limited, none needs a token. | `POST /checkout` | `{tier, cycle, seats?, discount_code?, attribution?}` | `{url}` — passed to the checkout SDK | | `POST /discount/preview` | `{code}` | `{valid, amount_type?, amount?}` | | `POST /newsletter/subscribe` | `{email}` | `{type, message}` | -| `POST /beta/signup` | `{email}` | `{type, message}` | | `GET /api/newsletter/stats` | — | `{count}` | Checkout takes a tier and billing cycle rather than a product identifier, which is why no payment-provider identifier appears anywhere in this repository. +`POST /beta/signup` was the fifth row here until 2026-09-22. The endpoint still +exists on the backend and nginx still proxies `/beta`, but nothing on this site +calls it: the iPhone app shipped on the App Store, so the TestFlight invite form +in the closing call to action became a link. Do not add a caller back without +checking the endpoint is still wired. + ## Purchase attribution Neither half of this system can answer "where did this customer come from" on diff --git a/public/images/app-store-dark.svg b/public/images/app-store-dark.svg new file mode 100644 index 0000000..16c0496 --- /dev/null +++ b/public/images/app-store-dark.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_wht_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/app-store-light.svg b/public/images/app-store-light.svg new file mode 100644 index 0000000..072b425 --- /dev/null +++ b/public/images/app-store-light.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/blog/tablepro-for-iphone-table.png b/public/images/blog/tablepro-for-iphone-table.png new file mode 100644 index 0000000..b18dc4c Binary files /dev/null and b/public/images/blog/tablepro-for-iphone-table.png differ diff --git a/public/images/ios/row-dark.png b/public/images/ios/row-dark.png new file mode 100644 index 0000000..1d36fca Binary files /dev/null and b/public/images/ios/row-dark.png differ diff --git a/public/images/ios/row-light.png b/public/images/ios/row-light.png new file mode 100644 index 0000000..0fa4d0a Binary files /dev/null and b/public/images/ios/row-light.png differ diff --git a/public/images/ios/structure-dark.png b/public/images/ios/structure-dark.png new file mode 100644 index 0000000..0be5746 Binary files /dev/null and b/public/images/ios/structure-dark.png differ diff --git a/public/images/ios/structure-light.png b/public/images/ios/structure-light.png new file mode 100644 index 0000000..54de57c Binary files /dev/null and b/public/images/ios/structure-light.png differ diff --git a/public/images/ios/table-dark.png b/public/images/ios/table-dark.png new file mode 100644 index 0000000..1c012ac Binary files /dev/null and b/public/images/ios/table-dark.png differ diff --git a/public/images/ios/table-light.png b/public/images/ios/table-light.png new file mode 100644 index 0000000..b18dc4c Binary files /dev/null and b/public/images/ios/table-light.png differ diff --git a/public/og/blog/tablepro-for-iphone.png b/public/og/blog/tablepro-for-iphone.png new file mode 100644 index 0000000..92d016a Binary files /dev/null and b/public/og/blog/tablepro-for-iphone.png differ diff --git a/public/og/database/beancount-client.png b/public/og/database/beancount-client.png new file mode 100644 index 0000000..6a02e3b Binary files /dev/null and b/public/og/database/beancount-client.png differ diff --git a/public/og/database/pglite-client.png b/public/og/database/pglite-client.png new file mode 100644 index 0000000..f3dbace Binary files /dev/null and b/public/og/database/pglite-client.png differ diff --git a/public/og/database/surrealdb-client.png b/public/og/database/surrealdb-client.png new file mode 100644 index 0000000..5a71e93 Binary files /dev/null and b/public/og/database/surrealdb-client.png differ diff --git a/public/og/database/teradata-client.png b/public/og/database/teradata-client.png new file mode 100644 index 0000000..7437f7f Binary files /dev/null and b/public/og/database/teradata-client.png differ diff --git a/public/og/database/trino-client.png b/public/og/database/trino-client.png new file mode 100644 index 0000000..a46f31d Binary files /dev/null and b/public/og/database/trino-client.png differ diff --git a/public/site.webmanifest b/public/site.webmanifest index 9ac339c..f6dad99 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1,7 +1,7 @@ { "name": "TablePro", "short_name": "TablePro", - "description": "Fast, native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and more.", + "description": "Native database clients for Mac, iPhone and iPad. Free and open source.", "start_url": "/", "display": "browser", "background_color": "#ffffff", @@ -9,7 +9,7 @@ "icons": [ { "src": "/logo.png", - "sizes": "512x512", + "sizes": "256x256", "type": "image/png" } ] diff --git a/resources/blog/tablepro-for-iphone.md b/resources/blog/tablepro-for-iphone.md new file mode 100644 index 0000000..e0599a6 --- /dev/null +++ b/resources/blog/tablepro-for-iphone.md @@ -0,0 +1,70 @@ +--- +slug: tablepro-for-iphone +title: "TablePro for iPhone and iPad" +description: The iOS app is on the App Store. Ten engines with every driver built in, SSH tunnels, Face ID, and the same connections you already use on the Mac. Free, with no in-app purchases. +date: 2026-09-22 +author: TablePro Team +tags: [release, ios, ipados, icloud-sync] +ogPunchline: Ten engines on the phone. Every driver built in. +--- + +TablePro for iPhone and iPad is on the App Store. It is free, there are no in-app purchases, and it needs iOS or iPadOS 18. + +It is not the Mac app on a smaller screen. It opens the same connections, and then it does the part of the job that makes sense on a phone: read a table, check a row, run a query you already know you need. + +
+ The Chinook Track table open in TablePro on iPhone, listing TrackId, Name, AlbumId and MediaTypeId for the first rows, with a pager reading 1-100 of 3503 and Tables, Query, History and Info along the bottom +
The bundled Chinook database, two taps from the connection list. There is something to read before you have set up a server.
+
+ +## Ten engines, nothing to install + +The connection form offers MySQL, MariaDB, TiDB, OceanBase, PostgreSQL, SQLite, DuckDB, Redis, SQL Server and Oracle. + +Every driver is compiled into the app. There is no plugin registry here and nothing downloads the first time you pick an engine, which is the opposite of how the Mac app works and the right trade on a device where you may be on cellular and in a hurry. + +A Redshift connection made on a Mac opens here too once it syncs across. It is not in the picker, so the phone cannot create one. + +## The connections you already have + +iCloud sync carries connections, groups and tags. It is off until you turn it on. Passwords are a second switch inside it and travel through iCloud Keychain. + +Saved queries, SSH profiles and app settings stay on the Mac. Three record types sync from the phone, where the Mac syncs ten. + +You can also skip iCloud entirely and open a `.tablepro` file through Files or AirDrop. + +## Writing + +Open a row full screen, change values, set NULL, save. Insert and delete rows, truncate or drop a table. + +On MySQL, MariaDB, TiDB, OceanBase, PostgreSQL, Redshift and Oracle a save runs inside a transaction. SQLite, DuckDB and SQL Server do not report session state to the driver, so a single-statement write there runs without an explicit `BEGIN`. + +Safe Mode is per connection, on the connection form under Organization: Off, Confirm Writes, or Read-Only. Read-Only refuses a write before it reaches the server. + +## A query you can walk away from + +A long query runs in a Live Activity, so you can watch it from the Lock Screen or the Dynamic Island instead of holding the app open. There is a Stop button that works while it runs. + +Results copy out as JSON, CSV or SQL INSERT, to the clipboard or the share sheet. There is no file export dialog on the phone. + +The rest — browsing, filtering, SSH, SSL, biometrics — is on [the iPhone page](/ios). + +## What it will not do + +There is no AI chat, no MCP server, no ER diagrams, no Compare & Sync, no Query Insights and no charts. Those are the Mac app and there is no plan to shrink them onto a phone. + +There are no saved queries, only per-connection history. + +There is no plugin registry, so the ten engines above are the ten engines, full stop. Connections to anything else on your Mac's longer list will sync down and appear in the list, and will not open. + +SSH tunnels that need a jump host are refused before they dial. Open those on the Mac. + +SQLite and DuckDB read files on the device or in memory. Remote DuckDB is Mac-only. + +## Getting it + +It is on the [App Store](https://apps.apple.com/app/tablepro/id6761621829), free, for iPhone and iPad on iOS or iPadOS 18 or later. + +The source is in the [same repository](https://github.com/TableProApp/TablePro) as the Mac app, under AGPLv3, in `TableProMobile/`. The two version separately: the Mac app is on 0.75 and this one starts at 1.0. + +Usage data is off until you turn it on, and it never carries hostnames, usernames, passwords, queries or rows. diff --git a/resources/data/comparisons.json b/resources/data/comparisons.json index d15dd0c..d6e3ed6 100644 --- a/resources/data/comparisons.json +++ b/resources/data/comparisons.json @@ -223,7 +223,7 @@ "80 MB at idle vs ~800 MB", "Native macOS, no Java runtime", "AI SQL assistant in the free tier", - "iOS app with iCloud sync", + "Free iPhone and iPad app", "Open source under AGPLv3" ], "prosCompetitor": [ @@ -368,7 +368,7 @@ "Native macOS, not Java", "1 second start vs ~10 seconds", "80 MB RAM vs 1-2 GB", - "iOS app with iCloud sync", + "Free iPhone and iPad app", "Open source under AGPLv3" ], "prosCompetitor": [ @@ -518,7 +518,7 @@ "29 databases in one app", "Native macOS", "AI SQL assistant in the free tier", - "iOS app with iCloud sync", + "Free iPhone and iPad app", "Open source under AGPLv3" ], "prosCompetitor": [ @@ -670,7 +670,7 @@ "Native SwiftUI, not Electron", "Apple Silicon arm64 native", "AI SQL assistant in the free tier (Beekeeper requires Pro)", - "iOS app with iCloud sync" + "Free iPhone and iPad app" ], "prosCompetitor": [ "Cross-platform (Linux, Windows, Mac)", @@ -828,7 +828,7 @@ "Active development with regular releases", "29 databases, not just MySQL", "AI SQL assistant in the free tier", - "iOS app with iCloud sync", + "Free iPhone and iPad app", "Modern SwiftUI on macOS Sonoma+", "Open source under AGPLv3" ], @@ -989,7 +989,7 @@ "Free vs $69 personal or $99 commercial", "29 databases including MySQL, Mongo, Redis", "AI SQL assistant", - "iOS app with iCloud sync", + "Free iPhone and iPad app", "ER diagrams, Vim mode, MCP server", "Open source under AGPLv3" ], @@ -999,7 +999,7 @@ "Eggerapps build quality", "First-class Redshift, CockroachDB, Greenplum support" ], - "chooseTablePro": "you work with multiple databases or want AI and iOS support in the free tier.", + "chooseTablePro": "you work with more than one database, or want the AI assistant and the iPhone app without paying for either.", "chooseCompetitor": "Postgres, Redshift, CockroachDB, or Greenplum is your only database and you prefer paying once for a focused tool.", "verdict": "Postico is excellent for Postgres alone. TablePro covers the same ground for Postgres plus 28 other databases, free.", "keywords": [ @@ -1150,7 +1150,7 @@ "Active development with regular releases", "29 databases, not just MySQL", "AI SQL assistant in the free tier", - "iOS app with iCloud sync", + "Free iPhone and iPad app", "Modern SwiftUI on macOS Sonoma+", "Open source under AGPLv3" ], @@ -1354,7 +1354,7 @@ }, { "title": "Use macOS features", - "description": "Touch ID, iCloud sync, and dark mode work because TablePro is built only for macOS." + "description": "Touch ID, iCloud sync and dark mode work because TablePro is a native Mac app, not a web app in a window." } ], "faqs": [ @@ -1468,7 +1468,7 @@ "Edit data inline, run queries, manage table structure", "Native macOS, works offline", "29 databases including NoSQL", - "iOS app with iCloud sync", + "Free iPhone and iPad app", "AI SQL assistant" ], "prosCompetitor": [ @@ -1636,7 +1636,7 @@ "Native macOS UX with Touch ID and dark mode", "29 databases, not just MySQL", "AI SQL assistant", - "iOS app with iCloud sync" + "Free iPhone and iPad app" ], "prosCompetitor": [ "Pre-installed on most cPanel hosts", diff --git a/resources/js/components/landing/app-store-badge.tsx b/resources/js/components/landing/app-store-badge.tsx new file mode 100644 index 0000000..750bf79 --- /dev/null +++ b/resources/js/components/landing/app-store-badge.tsx @@ -0,0 +1,54 @@ +import ThemedImage from '@/components/ui/themed-image'; +import { APP_STORE_URL } from '@/data/links'; +import { trackDownload } from '@/lib/analytics'; + +/** + * Apple's "Download on the App Store" badge, in the reader's OS theme. + * + * Copied into `public/images` rather than hotlinked, unlike `ProductHuntBadge`: + * Product Hunt draws a live upvote count into its SVG, so a local copy freezes. + * Apple's badge is static artwork, and Apple's guidelines ask that it be used as + * supplied, so a pinned local file is the correct form here and the hotlink is + * the exception. + * + * Both files are byte-for-byte what Apple's Marketing Tools serve, at their + * native 119.66407 x 40. Do not run them through SVGO, reformat them, or change + * the viewBox — the badge is trademarked artwork and the guidelines forbid + * altering it. + * + * The `-light` / `-dark` suffixes follow this repository's convention, which + * means "the variant shown while the OS is in that mode" — NOT the colour of the + * artwork. Apple's BLACK badge is `app-store-light.svg`, because a black badge + * is what goes on a light background. Anyone checking by grepping the fills + * inside the two files will read it backwards. + * + * Rendered through ``, never inlined as JSX. Twenty-four of the white + * badge's twenty-five paths carry no `fill` of their own and rely on the SVG + * default, so an ancestor `fill-current` would silently recolour Apple's mark; + * and both files carry `id="livetype"`, so inlining the pair would duplicate a + * DOM id on any page showing both. + * + * `h-10` is Apple's minimum rendered height of 40px. `w-auto` keeps the aspect + * ratio coming from the file rather than from the two attributes, which are + * rounded and exist only to reserve layout space. + */ +export default function AppStoreBadge({ location, className }: { location: string; className?: string }) { + return ( + trackDownload(location, 'ios')} + className={`inline-block ${className ?? ''}`} + > + + + ); +} diff --git a/resources/js/components/landing/database-grid.tsx b/resources/js/components/landing/database-grid.tsx index 65e021e..4271a64 100644 --- a/resources/js/components/landing/database-grid.tsx +++ b/resources/js/components/landing/database-grid.tsx @@ -8,7 +8,7 @@ import DatabaseMark from '@/components/ui/database-mark'; import SectionShell from '@/components/ui/section-shell'; import { CELL_DENSITY, cellBorders, GridCell, type ColumnMap } from '@/components/ui/grid-cell'; import { GITHUB_REPO_URL } from '@/data/links'; -import { BUNDLED_ENGINE_COUNT, ENGINE_COUNT, ON_DEMAND_ENGINE_COUNT } from '@/data/engines'; +import { BUNDLED_ENGINE_COUNT, DRIVER_PLUGIN_COUNT, ENGINE_COUNT, ON_DEMAND_ENGINE_COUNT } from '@/data/engines'; interface DatabaseTile { name: string; @@ -328,10 +328,21 @@ export default function DatabaseGrid() { * row folded in here because it is a reassurance about the parts * list, not a second subject. */} + {/* + * Interpolated, not spelled out. + * + * This read "Twenty six tiles, twenty five drivers" until + * 2026-09-22, against a grid holding 29 and a driver count of 23. + * It survived `EngineCountTest` for the length of its life because + * that guard matches a digit beside "tiles" or "drivers", and a + * number written as a word is invisible to it. Numerals here come + * from `engines.ts` so the sentence cannot drift from the grid a + * reader can count two rows up. + */} - Twenty six tiles, twenty five drivers: Cassandra and ScyllaDB share one. Underneath are libpq, - libmariadb, hiredis, libmongoc, libcassandra, FreeTDS and OracleNIO, with Teradata and Trino speaking - their wire protocols in pure Swift. + {ENGINE_COUNT} tiles over {DRIVER_PLUGIN_COUNT} driver plugins: one driver can serve several engines. + Underneath are libpq, libmariadb, hiredis, libmongoc, libcassandra, FreeTDS and OracleNIO, with + Teradata and Trino speaking their wire protocols in pure Swift. ); diff --git a/resources/js/components/landing/footer-cta.tsx b/resources/js/components/landing/footer-cta.tsx index 00be575..c2ce6c4 100644 --- a/resources/js/components/landing/footer-cta.tsx +++ b/resources/js/components/landing/footer-cta.tsx @@ -1,5 +1,3 @@ -import { useState } from 'react'; -import { buttonClasses } from '@/components/ui/button'; import CopyButton from '@/components/ui/copy-button'; import Container from '@/components/ui/container'; import FootNote from '@/components/ui/footnote'; @@ -9,30 +7,13 @@ import SectionShell from '@/components/ui/section-shell'; import ThemedImage from '@/components/ui/themed-image'; import Button from '@/components/ui/button'; import { AppleGlyph } from '@/components/ui/glyph'; +import AppStoreBadge from '@/components/landing/app-store-badge'; +import { PROSE_LINK } from '@/components/ui/prose-link'; import { trackDownload } from '@/lib/analytics'; -import { useEmailForm, type FlashMessage } from '@/hooks/use-email-form'; const COLS: ColumnMap = { base: 1, sm: 2 }; const BREW_COMMAND = 'brew install --cask tablepro'; -const flashColors = { - success: 'text-green-600 dark:text-green-400', - warning: 'text-amber-600 dark:text-amber-400', - error: 'text-red-600 dark:text-red-400', -}; - -function FlashStatus({ flash }: { flash?: FlashMessage | null }) { - if (!flash?.message) { - return null; - } - - return ( -

- {flash.message} -

- ); -} - /** * The iPhone frame is decorative chrome; the screenshot inside carries the alt * text. The positioning percentages belong to this exact SVG, so they move with @@ -54,7 +35,7 @@ function PhoneMockup() {
-

Download

+

Mac

+
- {showBeta && ( -
-

- Enter your email to get a TestFlight invite. -

-
- - betaForm.setEmail(e.target.value)} - placeholder="you@example.com" - disabled={betaForm.processing} - className={INPUT_CLASS} - /> - -
- {betaForm.error && ( -

- {betaForm.error} -

- )} - -
- )} +

+ What it does on iPhone → +

diff --git a/resources/js/components/landing/header.tsx b/resources/js/components/landing/header.tsx index 4b39d28..941b67f 100644 --- a/resources/js/components/landing/header.tsx +++ b/resources/js/components/landing/header.tsx @@ -176,16 +176,19 @@ export default function Header({ githubStars }: Props) { { itemRefs.current[1] = el; }} role="menuitem" - href="/#mobile" + href="/ios" className="flex items-center gap-3 px-4 py-3 text-sm text-foreground transition-colors" - onClick={closeDropdown} + onClick={() => { + trackDownload('header-menu', 'ios'); + closeDropdown(); + }} >
Get for iPhone
-
iOS 18+ · TestFlight beta
+
iPhone and iPad · App Store
diff --git a/resources/js/components/landing/mobile-nav.tsx b/resources/js/components/landing/mobile-nav.tsx index f6d0bfa..d236e2d 100644 --- a/resources/js/components/landing/mobile-nav.tsx +++ b/resources/js/components/landing/mobile-nav.tsx @@ -168,11 +168,14 @@ export default function MobileNav({ isOpen, onClose }: Props) { Download for Mac { + trackDownload('mobile-nav', 'ios'); + onClose(); + }} className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground" > - Get for iPhone (beta) + TablePro for iPhone
diff --git a/resources/js/data/faqs.ts b/resources/js/data/faqs.ts index 80434c2..e80accd 100644 --- a/resources/js/data/faqs.ts +++ b/resources/js/data/faqs.ts @@ -25,7 +25,7 @@ export interface FaqItem { export const faqs: FaqItem[] = [ { question: 'Is it really free, or free for now?', - answer: `Free, permanently. TablePro is AGPLv3 and the whole app works without a license, with no trial countdown and no per-Mac limit. All ${ENGINE_COUNT} databases, the SQL editor, the data grid, the AI assistant, the MCP server, Safe Mode with Touch ID, SSH tunnels, ER diagrams and XLSX export cost nothing. Starter adds seven: Compare & Sync, Query Insights, result charts, iCloud Sync, Linked Folders, encrypted connection export, and environment variables in connection fields. Team adds two more, the Team Catalog and the Team Library.`, + answer: `Free, permanently. TablePro is AGPLv3 and the whole app works without a license, with no trial countdown and no per-Mac limit. All ${ENGINE_COUNT} databases, the SQL editor, the data grid, the AI assistant, the MCP server, Safe Mode with Touch ID, SSH tunnels, ER diagrams and XLSX export cost nothing. Starter adds eight: Compare & Sync, Query Insights, result charts, Data Rewind, iCloud Sync, Linked Folders, encrypted connection export, and environment variables in connection fields. Team adds two more, the Team Catalog and the Team Library. The iPhone and iPad app is free outright and gates nothing.`, }, { question: 'Can I use TablePro at work under AGPLv3?', @@ -59,7 +59,7 @@ export const faqs: FaqItem[] = [ }, { question: 'Windows or Linux?', - answer: 'macOS 14 or later and iOS 18 or later today. A native Linux app is being built in Rust with GTK4, with PostgreSQL, MySQL, SQLite and SQL Server working already, but it is not ready for a beta and there is nothing to install yet. There is no Windows version.', + answer: 'macOS 14 or later, and iOS or iPadOS 18 or later. A native Linux app is being built in Rust with GTK4, with PostgreSQL, MySQL, SQLite and SQL Server working already, but it is not ready for a beta and there is nothing to install yet. There is no Windows version.', }, { question: 'Do I need to pay for the AI features?', @@ -67,11 +67,11 @@ export const faqs: FaqItem[] = [ }, { question: 'Does it phone home?', - answer: 'There is no account and no login. Anonymous usage data is a single toggle in Settings and contains no personal data and no queries. iCloud Sync is off by default, and syncing passwords is a separate opt in inside it. The source is public, so you can check.', + answer: 'There is no account and no login. Anonymous usage data is a single toggle in Settings, and it carries no hostnames, usernames, passwords, queries or rows. The default differs by platform: on the Mac it is on and you can turn it off, on iPhone and iPad it is off until you turn it on during first run. iCloud Sync is off by default on both, and syncing passwords is a separate opt in inside it. The source is public, so you can check.', }, { question: 'How many Macs does one license cover?', - answer: 'Starter activates two Macs. Team activates five as a minimum, then one per purchased seat. Activation is bound to a hashed hardware ID, the list shows every activated machine, and Deactivate frees the slot. Activations never sync, and a license restored from another Mac backup is cleared on launch.', + answer: 'Starter activates two Macs. Team activates five as a minimum, then one per purchased seat. Activation is bound to a hashed hardware ID, the list shows every activated machine, and Deactivate frees the slot. Activations never sync, and a license restored from another Mac backup is cleared on launch. An iPhone or iPad never takes a slot: the mobile app has no licensing in it at all.', }, { question: 'Does it work offline?', @@ -79,10 +79,10 @@ export const faqs: FaqItem[] = [ }, { question: 'What happens when my subscription ends?', - answer: 'The app keeps working. iCloud Sync stops and the Starter and Team screens show an activation overlay again. Your local connections, queries, favorites and settings are untouched.', + answer: 'The Mac app keeps working. iCloud Sync pauses there and the Starter and Team screens show an activation overlay again. Your local connections, queries, favorites and settings are untouched. Nothing changes on iPhone or iPad, which keeps syncing its own connections, groups and tags — it simply stops hearing from a Mac that has paused.', }, { question: 'Is there an iPhone app?', - answer: 'Yes, for iOS and iPadOS 18 or later. Seven engines on device, SSH tunnels, Face ID lock, Handoff with your Mac, and Shortcuts actions that insert rows without opening the app. It shares your iCloud connections, groups and tags. It is a companion to the Mac app, not a copy of it: no plugin registry, no AI chat, no MCP server and no ER diagrams.', + answer: 'Yes, free on the App Store, for iPhone and iPad running iOS or iPadOS 18 or later. The connection form offers MySQL, MariaDB, TiDB, OceanBase, PostgreSQL, SQLite, DuckDB, Redis, SQL Server and Oracle, with SSH tunnels, SSL, Face ID and Handoff, and Shortcuts actions that add a row without opening the app. It syncs connections, groups and tags with your Mac over iCloud. It is a companion to the Mac app, not a copy of it: no plugin registry, no AI chat, no MCP server, no ER diagrams and no saved queries. There is more detail at tablepro.app/ios.', }, ]; diff --git a/resources/js/data/license.ts b/resources/js/data/license.ts index b7df560..b7fe0bc 100644 --- a/resources/js/data/license.ts +++ b/resources/js/data/license.ts @@ -1,16 +1,21 @@ /** * Every feature the app actually gates, in one place. * - * The source of truth is `ProFeature.swift` in the app repository: nine cases, - * seven of them `requiredTier == .starter` and two `.team`. This site used to + * The source of truth is `ProFeature.swift` in the app repository: ten cases, + * eight of them `requiredTier == .starter` and two `.team`. This site used to * name four of them and said so out loud — "a license adds four things" — in * the pricing lede and again in the FAQ, while Compare & Sync, Query Insights, * Result Charts and Linked Folders were not mentioned anywhere on the domain. * Three of those four are the features a competitor charges most for. * * Adding a case to `ProFeature` means adding it here. `LicenseFeaturesTest` - * fails when this list stops holding nine entries split seven/two, which is the + * fails when this list stops holding ten entries split eight/two, which is the * cheapest guard available from a repository that cannot read the Swift enum. + * + * Data Rewind was the case that proved the guard only works if someone runs it: + * it shipped in the app while this file still held nine, so the pricing table + * undersold Starter by a feature until 2026-09-22. The count in the test moved + * with it. */ export type PaidTier = 'starter' | 'team'; @@ -51,6 +56,11 @@ export const PAID_FEATURES: PaidFeature[] = [ detail: 'Bar, line, area and scatter, from rows already loaded.', tier: 'starter', }, + { + name: 'Data Rewind', + detail: 'Put back the values a row had before you saved.', + tier: 'starter', + }, { name: 'iCloud Sync', detail: 'Connections, queries, favorites and settings across your Macs.', diff --git a/resources/js/data/links.ts b/resources/js/data/links.ts index 7f4b46a..8d1e0c5 100644 --- a/resources/js/data/links.ts +++ b/resources/js/data/links.ts @@ -15,9 +15,20 @@ * appears. */ -/** The app's source, AGPLv3. */ +/** The app's source, AGPLv3. Both apps live here: `TableProMobile/` is the iOS target. */ export const GITHUB_REPO_URL = 'https://github.com/TableProApp/TablePro'; +/** + * The iPhone and iPad app on the App Store. + * + * No country segment. `apps.apple.com/app/...` geo-redirects to the reader's own + * storefront, while `/us/app/...` pins every visitor to the US store and shows a + * "not available in your country" interstitial to anyone who is signed in + * elsewhere. The listing ships in five languages, so the storefront the reader + * lands on matters. + */ +export const APP_STORE_URL = 'https://apps.apple.com/app/tablepro/id6761621829'; + /** * GitHub Sponsors, personal rather than organisational, because that is who * builds TablePro. diff --git a/resources/js/lib/analytics.ts b/resources/js/lib/analytics.ts index e40e8b4..8e26aab 100644 --- a/resources/js/lib/analytics.ts +++ b/resources/js/lib/analytics.ts @@ -29,7 +29,13 @@ export function trackEvent(name: string, props: Record = {}): vo * One event name for every download, with the section that produced it. Keeping * the name constant and varying only the prop is what makes the totals * comparable across a layout change. + * + * `platform` arrived with the App Store listing. It is a second prop rather than + * a second event name, and rather than a suffix on `location`, so that the total + * download count stays one number and the Mac/iOS split is a breakdown of it. + * The App Store click is the last thing this domain sees — Plausible cannot + * follow the reader to Apple — so it is the only signal there will be. */ -export function trackDownload(location: string): void { - trackEvent('download_click', { location }); +export function trackDownload(location: string, platform: 'mac' | 'ios' = 'mac'): void { + trackEvent('download_click', { location, platform }); } diff --git a/resources/js/lib/structured-data.ts b/resources/js/lib/structured-data.ts index b8e42c0..183a4ae 100644 --- a/resources/js/lib/structured-data.ts +++ b/resources/js/lib/structured-data.ts @@ -42,8 +42,16 @@ export function buildOrganizationJsonLd(baseUrl: string): object { width: 256, height: 256, }, + /* + * Two apps, not one, and the engine count belongs to the Mac. + * + * This used to read "a native database client for macOS, iPadOS and iOS + * covering N engines", which attached the Mac's grid to the phone. The + * iPhone app offers ten in its connection form, so the old sentence + * overstated it by the better part of twenty. + */ description: - `TablePro builds a native database client for macOS, iPadOS and iOS covering ${ENGINE_COUNT} engines, released as open source under AGPLv3.`, + `TablePro builds native database clients for macOS, iOS and iPadOS, released as open source under AGPLv3. The Mac app covers ${ENGINE_COUNT} engines.`, // All five profiles the footer links, not two of them. sameAs: [ GITHUB_REPO_URL, diff --git a/resources/js/pages/Compare.tsx b/resources/js/pages/Compare.tsx index 401ec0b..c885039 100644 --- a/resources/js/pages/Compare.tsx +++ b/resources/js/pages/Compare.tsx @@ -100,9 +100,16 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { canonical={canonical} ogImage={ogImage} jsonLd={jsonLd} + /* + * No `/compare` crumb. There is no `/compare` index route — + * routes/web.php goes straight from /blog/{slug} to + * /compare/{slug} — so all eleven of these pages were + * publishing a BreadcrumbList item pointing at a 404. Nothing + * tested it, because a breadcrumb's `item` is a URL in JSON-LD + * and never rendered as a link a crawler here would follow. + */ breadcrumbs={[ { name: 'Home', path: '/' }, - { name: 'Compare', path: '/compare' }, { name: comp.name, path: canonical }, ]} /> diff --git a/resources/js/pages/Home.tsx b/resources/js/pages/Home.tsx index 44d5cd5..134b1b8 100644 --- a/resources/js/pages/Home.tsx +++ b/resources/js/pages/Home.tsx @@ -82,6 +82,7 @@ const FEATURE_LIST = [ 'Compare & Sync between two databases', 'Query Insights over local query history', 'Charts from query results', + 'Data Rewind for rows already saved', 'iCloud Sync with an iPhone and iPad app', ]; diff --git a/resources/js/pages/Ios.tsx b/resources/js/pages/Ios.tsx new file mode 100644 index 0000000..4b1f9af --- /dev/null +++ b/resources/js/pages/Ios.tsx @@ -0,0 +1,454 @@ +import { usePage } from '@inertiajs/react'; +import LandingLayout from '@/layouts/landing-layout'; +import Header from '@/components/landing/header'; +import Footer from '@/components/landing/footer'; +import AppStoreBadge from '@/components/landing/app-store-badge'; +import Container from '@/components/ui/container'; +import SEOHead from '@/components/seo/seo-head'; +import SectionShell, { PageHeader } from '@/components/ui/section-shell'; +import { FullLine } from '@/components/ui/full-line'; +import FootNote from '@/components/ui/footnote'; +import { ITEM_TITLE, cellBorders, GridCell, type ColumnMap } from '@/components/ui/grid-cell'; +import ThemedImage from '@/components/ui/themed-image'; +import Button from '@/components/ui/button'; +import { AppleGlyph } from '@/components/ui/glyph'; +import { PROSE_LINK } from '@/components/ui/prose-link'; +import { APP_STORE_URL, GITHUB_REPO_URL } from '@/data/links'; +import { trackDownload } from '@/lib/analytics'; + +interface Props { + downloadUrls: { arm64: string; x86_64: string }; + githubStars?: number | null; +} + +const IOS_TITLE = 'TablePro for iPhone and iPad - Free Database Client'; +const IOS_DESCRIPTION = + 'A native database client for iPhone and iPad. Browse tables, run queries and edit rows on MySQL, PostgreSQL, SQL Server, Oracle, Redis and more. Free on the App Store.'; + +/** + * Engines the connection form on the phone actually offers, in the order the + * picker lists them. + * + * Named, not counted. The site published "Seven engines on device" for six + * weeks after it stopped being true: seven was the number of driver *classes* + * compiled into the app, while the picker offered ten, because MariaDB, TiDB + * and OceanBase all ride the MySQL driver. A list cannot drift the way a + * numeral can, and `EngineCountTest` forbids writing a digit beside the word + * "engines" in the files it scans for exactly this reason. + */ +const ENGINES = [ + 'MySQL', + 'MariaDB', + 'TiDB', + 'OceanBase', + 'PostgreSQL', + 'SQLite', + 'DuckDB', + 'Redis', + 'SQL Server', + 'Oracle', +]; + +const CAPABILITY_COLS: ColumnMap = { base: 1, sm: 2, lg: 3 }; + +/** + * Three captures from the bundled Chinook database, on an iPhone 16 Pro at 3x, + * which is the 1206x2622 the rest of this site's iOS artwork already uses. + * + * Each is a real screen with real rows, driven through the app by the + * `TableProMobileScreenshots` UI test in the app repository, so the light and + * dark files in a pair are the same frame in two palettes rather than two + * separate sessions. The pair that shipped before these disagreed on the + * status-bar clock, which is the tell that they were shot by hand a minute + * apart; the status bar is pinned to 09:41 here. + * + * The alt text describes what is actually on screen. The previous iOS + * screenshot on this site was captioned "showing rows from a PostgreSQL table" + * while showing a connection list. + */ +const IOS_SCREENS = [ + { + src: 'table', + alt: 'The Track table open on iPhone, showing TrackId, Name, AlbumId and MediaTypeId for the first rows of 3,503.', + caption: 'Browse a table', + }, + { + src: 'row', + alt: 'A single Chinook track open full screen on iPhone, one field per row, ready to edit.', + caption: 'Open a row', + }, + { + src: 'structure', + alt: 'The Track table structure on iPhone, listing each column with its type.', + caption: 'Read the structure', + }, +]; + +/** + * Six cells, each one thing the app does, in the order a reader meets them: + * open something, read it, change it, ask it a question, keep it safe, keep it + * in step with the Mac. + */ +const CAPABILITIES = [ + { + title: 'Browse', + body: 'Sort by any column, search every column but the binary ones, and stack filters with AND or OR. Fifty to five hundred rows a page. Tap a foreign key to preview the row it points at.', + }, + { + title: 'Edit', + body: 'Open a row full screen, change values, set NULL, save. Insert and delete rows, truncate or drop a table.', + }, + { + title: 'Query', + body: 'A SQL editor with syntax highlighting, and a Stop button while it runs. Every connection keeps its own history. Results copy out as JSON, CSV or SQL INSERT.', + }, + { + title: 'Safe Mode', + body: 'Per connection, on the connection form under Organization: Off, Confirm Writes, or Read-Only. Read-Only refuses a write before it reaches the server.', + }, + { + title: 'Connect', + body: 'SSH tunnels with a password or a private key, and host keys are checked. SSL with CA and client certificates. Passwords and keys live in the Keychain.', + }, + { + title: 'Lock', + body: 'Face ID, Touch ID or Optic ID on open, with your passcode as the fallback it always falls back to.', + }, +]; + +/** + * The limits, stated on the page rather than discovered after installing. + * + * Every release post on this site carries a "what it will not do" section and + * this is the same discipline: the list below is what makes the list above + * believable. It is also the honest answer to the question a Mac user actually + * arrives with, which is not "what does it do" but "is this the whole app". + */ +const LIMITS = [ + 'No AI chat and no MCP server. Those are the Mac app.', + 'No ER diagrams, no Compare & Sync, no Query Insights, no charts.', + 'No saved queries. History is per connection and stays on the device.', + 'No plugin registry. Every driver is compiled in, so there is nothing to install — and nothing to add either.', + 'SQLite and DuckDB open files on the device or in memory. There is no remote DuckDB here; that one is Mac-only.', + 'No jump hosts. A tunnel that needs one has to be opened on the Mac.', +]; + +function buildIosAppJsonLd(canonicalBaseUrl: string): object { + const base = canonicalBaseUrl.replace(/\/$/, ''); + + return { + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + /* + * A distinct @id from the homepage's `#app`. That node is the Mac app — + * its downloadUrl is a DMG page and its softwareRequirements name + * macOS. One node cannot stand for two applications that ship from two + * projects on two release cadences, which is the same reason the + * homepage stopped claiming `macOS 14+, iOS 18+` in one string. + */ + '@id': `${base}/#ios-app`, + name: 'TablePro for iPhone and iPad', + applicationCategory: 'DeveloperApplication', + applicationSubCategory: 'Database Client', + operatingSystem: 'iOS 18.0, iPadOS 18.0', + softwareRequirements: 'iOS 18.0 or later, iPadOS 18.0 or later', + description: IOS_DESCRIPTION, + url: `${base}/ios`, + downloadUrl: APP_STORE_URL, + installUrl: APP_STORE_URL, + license: `${GITHUB_REPO_URL}/blob/main/LICENSE`, + isAccessibleForFree: true, + featureList: [ + 'Ten database engines, every driver built in', + 'Browse, sort, filter and page through rows', + 'Full-screen row editing with NULL support', + 'SQL editor with syntax highlighting and query history', + 'Per-connection Safe Mode: off, confirm writes, or read-only', + 'SSH tunnels with host key checking', + 'SSL with CA and client certificates', + 'Face ID, Touch ID and Optic ID lock', + 'iCloud sync of connections, groups and tags', + 'Handoff with TablePro for Mac', + 'Quick Connect widget, Shortcuts and Siri actions', + 'Live Activity for a running query', + ], + publisher: { '@id': `${base}/#organization` }, + author: { '@id': `${base}/#organization` }, + /* + * No aggregateRating. The listing has no ratings and no reviews, and + * StaleClaimsTest bans the string on every page that emits one of these + * nodes — which now includes this file. + */ + offers: { + '@type': 'Offer', + price: '0', + priceCurrency: 'USD', + url: APP_STORE_URL, + availability: 'https://schema.org/InStock', + }, + }; +} + +/** + * The iPhone and iPad app. + * + * Its own route rather than a cell in the homepage's closing call to action, + * because it is the only iOS surface this domain owns and a reader searching + * for "database client iphone" has nowhere else to land. The closing CTA keeps + * the `#mobile` anchor and a badge; this page is where the detail lives. + * + * Every fact below was read out of the app repository rather than out of the + * App Store listing, because the listing is marketing copy and the site's rule + * is that a claim has a source. The three that most often get written wrong, + * all by reusing a Mac sentence: Safe Mode has three levels here and six on the + * Mac, export goes to the clipboard or the Share Sheet rather than to a file, + * and there are no jump hosts. + */ +export default function Ios({ downloadUrls, githubStars }: Props) { + const { canonicalBaseUrl } = usePage<{ canonicalBaseUrl: string }>().props; + + return ( + } footer={