diff --git a/.claude/skills/sponsors/SKILL.md b/.claude/skills/sponsors/SKILL.md new file mode 100644 index 00000000..142a32ab --- /dev/null +++ b/.claude/skills/sponsors/SKILL.md @@ -0,0 +1,75 @@ +--- +name: sponsors +description: > + Keeps resources/data/sponsors.json in step with GitHub Sponsors: who sponsors TablePro now, where + each one is placed, the open homepage places and the tier prices. Use when the user says "check + sponsors", "sponsor drift", "new sponsor", "add as a sponsor", "a sponsor cancelled", + "who sponsors us", or after changing the tiers on GitHub. Read-only against GitHub. +--- + +# Sponsors + +`resources/data/sponsors.json` is the only list the site reads: the homepage strip, `/sponsors` +and the open-place count all come from it. Nothing in the app calls GitHub for sponsors, because +GitHub's sponsorship API needs credentials and this repository holds none. This skill is the check. + +Commands run from this repository's root. + +## 1. Report + +```bash +python3 .claude/skills/sponsors/scripts/drift.py +``` + +It runs `gh api graphql` with the maintainer's own `gh` login, for the user in +`facts.json` → `links.sponsorsProgram` and the TableProApp organization. If `gh` reports a missing +scope, the user runs `gh auth refresh -h github.com -s read:user` themselves. + +| Code | Meaning | Fix | +|---|---|---| +| MISSING | A public sponsor whose amount earns a placement is not listed | Add it (step 2) | +| GONE | A listed sponsor has no current public sponsorship | Remove the entry and its logo file | +| PLACEMENT | Listed above or below what the amount earns | Change `placement` | +| PRIVATE | A private sponsor is listed | Remove it: private sponsors are never named | +| TIERS | Prices on GitHub and in `tiers` differ | Change whichever side is wrong, then rerun | +| STALE | `verifiedAt` is over 30 days old | Rerun and set it once clean | +| LEGACY | Info: kept from before the current tiers (`TIERS_SINCE` in the script) | Nothing | +| EXPIRES | Info: a one-time placement ends within 30 days | Remove it after that date | + +A one-time sponsorship counts for `oneTime.months` from its date, at `multiplier` times the +monthly price. GitHub drops one-time sponsors from its own list after a month, so only the script +tracks them. + +## 2. Change the data + +- One object per sponsor: `id` (kebab-case), `name` as the sponsor writes it, `githubLogin`, + `placement` (`lead`, `homepage` or `name`), `url` with `?ref=tablepro` where the site + takes one, and `logo` (`null` for `name`). +- Display order is the array order; a `lead` is drawn first wherever it sits. +- Logos: the file the sponsor sent, at `public/images/sponsors/{id}.svg|png|webp`, with its real + `width` and `height` (an SVG needs both attributes on its root). A dark-mode file is + `{id}-dark.*` with the same shape; without one the logo sits on a white tile. Never recolour or + filter a logo. Check a third-party SVG holds no script, link or embedded image. +- Set `verifiedAt` to the day of a clean report. + +Never write an amount, a tier per sponsor, a private sponsor or the report itself into the +repository: it is public. The report stays in the session. + +## 3. The README + +TablePro's README (TableProApp/TablePro) shows the same sponsors from `.github/assets/sponsors/`. +Regenerate those files from `public/images/sponsors` and update the README's Sponsors section in a +PR there, after this one deploys: + +```bash +python3 .claude/skills/sponsors/scripts/readme_tiles.py ../TablePro/.github/assets/sponsors +``` + +## 4. Ship + +```bash +php artisan test --compact --filter='Sponsors|HomepageRender' +npm run test:js +``` + +Commit as `chore(sponsors): ...` on a branch and open a PR. `main` deploys itself. diff --git a/.claude/skills/sponsors/scripts/drift.py b/.claude/skills/sponsors/scripts/drift.py new file mode 100644 index 00000000..d2e341c1 --- /dev/null +++ b/.claude/skills/sponsors/scripts/drift.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# Compares GitHub Sponsors with resources/data/sponsors.json and prints what differs. +# Read-only: it runs queries through `gh`, which holds the credentials, and writes nothing. +import argparse +import json +import subprocess +import sys +from datetime import date, datetime, timedelta +from pathlib import Path + +WEB_ROOT = Path(__file__).resolve().parents[4] +DATA = WEB_ROOT / "resources" / "data" / "sponsors.json" +FACTS = WEB_ROOT / "resources" / "data" / "facts.json" + +# Sponsorships started or last changed before the current tiers went live keep the placement they had. +TIERS_SINCE = date(2026, 10, 29) +STALE_DAYS = 30 +EXPIRY_WARNING_DAYS = 30 + +SPONSORSHIPS = """ +query($login: String!, $endCursor: String) { + %s(login: $login) { + sponsorshipsAsMaintainer(first: 100, after: $endCursor, activeOnly: false, includePrivate: true) { + pageInfo { hasNextPage endCursor } + nodes { + isActive isOneTimePayment privacyLevel createdAt tierSelectedAt + tier { monthlyPriceInDollars isOneTime isCustomAmount } + sponsorEntity { __typename ... on User { login name } ... on Organization { login name } } + } + } + } +} +""" + +TIERS = """ +query($login: String!) { + user(login: $login) { + sponsorsListing { tiers(first: 50) { nodes { monthlyPriceInDollars isOneTime isCustomAmount } } } + } +} +""" + + +def gh(query, **variables): + args = ["gh", "api", "graphql", "-f", "query=" + query] + for key, value in variables.items(): + if value is not None: + args += ["-F", f"{key}={value}"] + result = subprocess.run(args, capture_output=True, text=True) + if result.returncode != 0: + sys.exit("gh api graphql failed. If it names a missing scope, run `gh auth refresh -h github.com -s read:user`.\n" + result.stderr.strip()) + return json.loads(result.stdout)["data"] + + +def sponsorships(kind, login): + nodes, cursor = [], None + while True: + page = gh(SPONSORSHIPS % kind, login=login, endCursor=cursor)[kind]["sponsorshipsAsMaintainer"] + nodes += page["nodes"] + if not page["pageInfo"]["hasNextPage"]: + return nodes + cursor = page["pageInfo"]["endCursor"] + + +def day(stamp): + return datetime.fromisoformat(stamp.replace("Z", "+00:00")).date() + + +def rank(placement, data): + order = [tier["placement"] for tier in data["tiers"]] + return -1 if placement is None else order.index(placement) + + +def earned(amount, one_time, data): + multiplier = data["oneTime"]["multiplier"] if one_time else 1 + placement = None + for tier in data["tiers"]: + if amount >= tier["monthly"] * multiplier: + placement = tier["placement"] + return placement + + +def price_label(amount, one_time): + return f"${amount}" + (" one time" if one_time else " a month") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--today", type=date.fromisoformat, default=date.today()) + args = parser.parse_args() + today = args.today + + data = json.loads(DATA.read_text()) + facts = json.loads(FACTS.read_text()) + program = facts["links"]["sponsorsProgram"] + maintainer = program.rstrip("/").rsplit("/", 1)[-1] + months = data["oneTime"]["months"] + + nodes = sponsorships("user", maintainer) + sponsorships("organization", "TableProApp") + listed = {sponsor["githubLogin"].lower(): sponsor for sponsor in data["sponsors"]} + + drift = 0 + + def report(code, text, counts=True): + nonlocal drift + drift += counts + print(f"{code:<10} {text}") + + current, private = {}, 0 + for node in nodes: + entity = node["sponsorEntity"] + if entity is None or node["tier"] is None: + continue + started = day(node["createdAt"]) + changed = day(node["tierSelectedAt"] or node["createdAt"]) + one_time = node["isOneTimePayment"] + ends = started + timedelta(days=round(months * 365 / 12)) if one_time else None + live = (ends is not None and ends > today) if one_time else node["isActive"] + if not live: + continue + if node["privacyLevel"] != "PUBLIC": + private += 1 + if entity["login"].lower() in listed: + report("PRIVATE", f"{entity['login']} sponsors privately but is listed: remove it") + continue + sponsor = { + "login": entity["login"], + "name": entity.get("name") or entity["login"], + "kind": entity["__typename"], + "amount": node["tier"]["monthlyPriceInDollars"], + "oneTime": one_time, + "ends": ends, + "legacy": changed < TIERS_SINCE, + "placement": earned(node["tier"]["monthlyPriceInDollars"], one_time, data), + } + # One login can hold a monthly and a one-time sponsorship at once: the better placement counts. + known = current.get(entity["login"].lower()) + if known is None or rank(sponsor["placement"], data) > rank(known["placement"], data): + current[entity["login"].lower()] = sponsor + + for login, sponsor in sorted(current.items()): + price = price_label(sponsor["amount"], sponsor["oneTime"]) + entry = listed.get(login) + if entry is None: + if sponsor["placement"] is not None: + report("MISSING", f"{sponsor['login']} ({sponsor['name']}, {sponsor['kind']}, {price}) earns placement '{sponsor['placement']}'") + continue + if sponsor["placement"] != entry["placement"]: + earns = sponsor["placement"] or "no placement" + if sponsor["legacy"]: + report("LEGACY", f"{sponsor['login']} ({price}) keeps '{entry['placement']}' from before {TIERS_SINCE}", counts=False) + else: + report("PLACEMENT", f"{sponsor['login']} ({price}) is listed as '{entry['placement']}' but earns {earns}") + if sponsor["ends"] is not None and (sponsor["ends"] - today).days <= EXPIRY_WARNING_DAYS: + report("EXPIRES", f"{sponsor['login']}'s one-time placement ends on {sponsor['ends']}", counts=False) + + for login, entry in sorted(listed.items()): + if login not in current: + report("GONE", f"{entry['githubLogin']} ({entry['name']}) has no current public sponsorship: remove it and its logo") + + tiers = gh(TIERS, login=maintainer)["user"]["sponsorsListing"]["tiers"]["nodes"] + published = {(tier["monthlyPriceInDollars"], tier["isOneTime"]) for tier in tiers if not tier["isCustomAmount"]} + wanted = {(tier["monthly"], False) for tier in data["tiers"]} + wanted |= {(tier["monthly"] * data["oneTime"]["multiplier"], True) for tier in data["tiers"]} + for amount, one_time in sorted(wanted - published): + report("TIERS", f"{price_label(amount, one_time)} is in sponsors.json but not published on GitHub") + for amount, one_time in sorted(published - wanted): + report("TIERS", f"{price_label(amount, one_time)} is published on GitHub but not in sponsors.json") + + on_homepage = sum(entry["placement"] in ("lead", "homepage") for entry in data["sponsors"]) + age = (today - date.fromisoformat(data["verifiedAt"])).days + if age > STALE_DAYS: + report("STALE", f"verifiedAt is {age} days old") + + print(f"\n{len(current)} current public sponsors, {private} private (never listed), " + f"{max(0, data['homepageSlots'] - on_homepage)} of {data['homepageSlots']} homepage places open.") + print("No drift." if drift == 0 else f"{drift} to fix.") + return 1 if drift else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/sponsors/scripts/readme_tiles.py b/.claude/skills/sponsors/scripts/readme_tiles.py new file mode 100644 index 00000000..9715e081 --- /dev/null +++ b/.claude/skills/sponsors/scripts/readme_tiles.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# Writes the README's sponsor logos, each on a white tile so a dark logo stays visible in GitHub's dark theme. +# Usage: readme_tiles.py [path to TablePro's .github/assets/sponsors] +import base64 +import json +import re +import sys +from pathlib import Path + +web = Path(__file__).resolve().parents[4] +out = Path(sys.argv[1]) if len(sys.argv) > 1 else web.parent / "TablePro" / ".github" / "assets" / "sponsors" +out.mkdir(parents=True, exist_ok=True) +for stale in out.glob("*.svg"): + stale.unlink() + +data = json.loads((web / "resources/data/sponsors.json").read_text()) +HEIGHT = 28 # the logo's height inside the tile, as on tablepro.app +PAD_X, PAD_Y, RADIUS = 8, 8, 6 + + +def tile(inner_w, inner_h, body): + w, h = inner_w + 2 * PAD_X, inner_h + 2 * PAD_Y + return ( + f'\n' + f'\n{body}\n\n' + ) + + +for sponsor in data["sponsors"]: + logo = sponsor["logo"] + if logo is None: + continue + width = round(HEIGHT * logo["width"] / logo["height"]) + source = web / "public" / logo["light"].lstrip("/") + if source.suffix == ".svg": + text = source.read_text() + root = re.search(r"]*>", text).group(0) + view_box = re.search(r'viewBox="([^"]+)"', root) + view_box = view_box.group(1) if view_box else f"0 0 {logo['width']} {logo['height']}" + inner = text[text.index(root) + len(root): text.rindex("")] + assert not re.search(r"{inner}' + else: + mime = {".png": "image/png", ".webp": "image/webp"}[source.suffix] + encoded = base64.b64encode(source.read_bytes()).decode() + body = f'' + (out / f"{sponsor['id']}.svg").write_text(tile(width, HEIGHT, body)) + print(sponsor["id"], width + 2 * PAD_X, HEIGHT + 2 * PAD_Y) diff --git a/CLAUDE.md b/CLAUDE.md index 8afc3fa4..4a77ea43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,6 +163,10 @@ in the job with a live SSR service. It skips locally without one. `facts.json` (URLs, limits), `sponsors.json`, `comparisons.json` (dated and sourced). Copy refers to them through `{token}` slots; counts are derived, never typed. After an app release, run `php artisan release:check`. +- `sponsors.json` holds each sponsor's placement and the tier prices, never + what a sponsor pays. The `sponsors` skill checks it against GitHub Sponsors + with the maintainer's `gh` login; the tier prices on GitHub and in the file + change together. - Content images are slots in `resources/data/assets.json`, rendered by `` (or `` in markdown). Until the owner supplies the file, a slot is a described placeholder outside production and renders diff --git a/app/Http/Controllers/LlmsTxtController.php b/app/Http/Controllers/LlmsTxtController.php index 260e50a5..aa4a6fe0 100644 --- a/app/Http/Controllers/LlmsTxtController.php +++ b/app/Http/Controllers/LlmsTxtController.php @@ -35,6 +35,7 @@ class LlmsTxtController extends Controller 'landing.faq', 'landing.security', 'landing.about', + 'landing.sponsors', ], 'Databases' => ['landing.databases.index', 'landing.databaseClient'], 'Comparisons' => ['landing.compare.index', 'landing.compare'], diff --git a/app/Http/Controllers/SponsorsController.php b/app/Http/Controllers/SponsorsController.php new file mode 100644 index 00000000..d5c809ae --- /dev/null +++ b/app/Http/Controllers/SponsorsController.php @@ -0,0 +1,21 @@ + $content->page('sponsors', App::getLocale()), + 'organizationProfiles' => $site->organizationProfiles(), + ]); + } +} diff --git a/app/Support/Seo/StaticPages.php b/app/Support/Seo/StaticPages.php index 79d158fb..53ad63ae 100644 --- a/app/Support/Seo/StaticPages.php +++ b/app/Support/Seo/StaticPages.php @@ -6,7 +6,7 @@ use App\Support\Localization\Locales; /** - * The one-off pages: home, download, iOS, pricing, FAQ, about, security and the blog index. + * The one-off pages: home, download, iOS, pricing, FAQ, about, security, sponsors and the blog index. * * Each renders in a locale when `resources/data/content/{locale}/{page}.json` * exists, and is indexable there unless that file sets `seo.indexable` to @@ -29,6 +29,7 @@ final class StaticPages implements PageFamily 'landing.faq' => 'faq', 'landing.about' => 'about', 'landing.security' => 'security', + 'landing.sponsors' => 'sponsors', 'landing.blog.index' => 'blog', ]; diff --git a/composer.lock b/composer.lock index 93300497..102da917 100644 --- a/composer.lock +++ b/composer.lock @@ -4856,16 +4856,16 @@ }, { "name": "symfony/mime", - "version": "v8.1.7", + "version": "v8.1.10", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283" + "reference": "8d1232052c9cdef170ed9608b3147bca4f61ff69" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/773ac57f20e2795bdadb65b5b1b5f4850d498283", - "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283", + "url": "https://api.github.com/repos/symfony/mime/zipball/8d1232052c9cdef170ed9608b3147bca4f61ff69", + "reference": "8d1232052c9cdef170ed9608b3147bca4f61ff69", "shasum": "" }, "require": { @@ -4918,7 +4918,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.7" + "source": "https://github.com/symfony/mime/tree/v8.1.10" }, "funding": [ { @@ -4938,7 +4938,7 @@ "type": "tidelift" } ], - "time": "2026-09-04T11:02:17+00:00" + "time": "2026-10-28T17:58:38+00:00" }, { "name": "symfony/polyfill-ctype", diff --git a/docs/rebuild/design/design-system.md b/docs/rebuild/design/design-system.md index bb37a09e..5acdc1de 100644 --- a/docs/rebuild/design/design-system.md +++ b/docs/rebuild/design/design-system.md @@ -597,7 +597,7 @@ Repo key: | `IntegrationFilters` | P | **New** | — | `/integrations` only: a `role="search"` GET form of native controls (search, category, TablePro app, type). Results update as you type once the page has hydrated | | `IntegrationList` | P | **New** | — | Integrations as ruled rows, rail to rail: mark, name, publisher, type, tagline. Not a card grid | | `DatabaseMark` | P | **Refactor** | `ui/database-mark.tsx` | Original colours, no `grayscale` / `opacity-40` / `invert`. Uses `iconDark` from data or a light tile | -| `SponsorList` | P | **Refactor** | `landing/sponsor-row.tsx` | The 4 verified sponsors only. Each link named | +| `SponsorList` | P | **Refactor** | `sponsors/sponsor-grid.tsx` | The homepage-placed sponsors from `sponsors.json`, then one invite cell. Each link named | | `ProseArticle` | P | **Refactor** | `.blog-article` CSS, `ui/prose-block.tsx` | Blog and legal. Body in `--foreground`. Permalink glyph kept out of the heading's accessible name | | `TableOfContents` | P | **New** (optional) | — | Legal and long guides: sticky at ≥ 1280, a `Disclosure` below that | | `AccountBar`, `AccountNav` | A | **Refactor** | A `dashboard/account-bar.tsx`, `nav-rail.tsx` | Adds language and theme. Rail bar uses `--accent-indicator` | @@ -991,10 +991,10 @@ One shared file, `components/shared/theme-control.tsx` (architecture §1.10 and **SponsorList:** -- Section 3 on the homepage. -- An H2 in the `h3` style, then four logo links at 28px tall with gaps of 32/48px. +- Section 3 on the homepage, and the `#companies` section of `/sponsors`. +- An H2 in the `h3` style, then one cell per sponsor with the logo 28px tall. A `lead` sponsor spans two cells with a 44px logo. - Each link is named (visible name in `caption` under the logo, or `aria-label`) with `rel="sponsored noopener"`. -- Then "Sponsor TablePro ↗" (GitHub Sponsors; sitemap §D, positioning §10.2). +- On the homepage, an invite cell after the last logo ("Your logo here", "7 of 12 open") and the heading link both go to `/sponsors`; the cell is hidden when no place is open. Only `/sponsors` links to GitHub Sponsors. - Logos follow the DatabaseMark rules in dark mode. #### 5.3.19 ProseArticle and TableOfContents @@ -1382,7 +1382,7 @@ SiteHeader one small line: built-in drivers vs drivers downloaded on first pick · the engines that open on iPhone (names) Supported databases → (/databases) §3 SPONSORS #sponsors compact · flush: the logo cells close on the next join - H2 (h3 style) · SponsorList (the 4 verified sponsors, cells 2 × 2, four across from 768) · Sponsor TablePro ↗ + H2 (h3 style) · SponsorList (homepage sponsors and an invite cell, two across, four from 768) · Sponsor TablePro → §4 WORKFLOWS #features H2, then five FeatureRows (H3 each) as cells, flush, in this order Query ▒ mac-query-autocomplete window row Querying → Edit data ▒ mac-edit-preview-sql window row Data editing → · Schema → diff --git a/docs/rebuild/design/sitemap.md b/docs/rebuild/design/sitemap.md index 29cabfa5..aba3f071 100644 --- a/docs/rebuild/design/sitemap.md +++ b/docs/rebuild/design/sitemap.md @@ -67,7 +67,7 @@ Column notes: - `pricing.json`: tiers, cycles, prices, seats, activations, refund window, license timings, the 10 paid features with tier, page anchor and lapse behaviour, merchant, currency, and the definition of Priority support. - `facts.json`: AI provider list, MCP clients, Safe Mode levels per platform, import sources, export formats, backup tools, sync categories, grid and editor limits. - `comparisons.json`: facts plus `checkedAt`, `sources[]` and three-state cells. - - `sponsors.json`: the 4 verified sponsors (CodeRabbit, SimpleLocalize, Nimbus, Dwarves Foundation). + - `sponsors.json`: the current sponsors with their placement, the homepage places and the tier prices. - `assets.json`: the placeholder manifest. - **Slots** are asset IDs from the slot catalogue in §A.8. One ID means one scene. A scene is reused only where it shows the same workflow on the same engine. - **Indexable "yes (pair)"** means `index,follow`, in the sitemap, self-canonical, with reciprocal `hreflang` between EN and VI and `x-default` set to EN. **"yes (EN only)"** means indexable, with no alternates. @@ -499,7 +499,7 @@ The same structure serves `/` and `/vi`. The ids are locale-neutral. |---|---|---|---|---|---|---| | 1 | `top` | **Hero** | What is TablePro, and can I use it on my devices? | H1 and subtitle (positioning decision). "Download for Mac" → /download, with the availability line below it ("macOS 13 Ventura or later · Apple Silicon and Intel"). The App Store badge, with its own line ("iPhone and iPad · iOS/iPadOS 18 or later · free"). A small link to Pricing | mac-hero-window (≥768px), mac-hero-window-mobile (<768px); the only `fetchpriority="high"` image on the site | platforms; `download_click{location:'hero', platform}` | | 2 | `databases` | **Databases** | Does it work with the database I use? | The featured engines with vendor marks; the number of engines, derived, with the hub's categories as links into /databases; one sentence on bundled drivers and drivers downloaded on first pick; the engines that open on iPhone; "Supported databases →" /databases | none (vendor marks are identity assets) | engines (names and categories; any count is derived) | -| 3 | `sponsors` | **Sponsors** (fixed third) | Who supports the project? | The 4 verified sponsors' logos with `rel="sponsored noopener"` and accessible names; "Sponsor TablePro" (GitHub Sponsors) | none (sponsor logos are identity) | sponsors.json | +| 3 | `sponsors` | **Sponsors** (fixed third) | Who supports the project? | The homepage-placed sponsors' logos with `rel="sponsored noopener"` and accessible names, an invite cell with the open places, and "Sponsor TablePro" (both to `/sponsors`) | none (sponsor logos are identity) | sponsors.json | | 4 | `features` | **Workflows** | What do I do with it day to day? | Five rows, each a heading, 2-3 sentences, one slot and a link: **Query** → /features/querying · **Edit data** → /features/data-editing · **Schemas and sync** (Compare & Sync marked Starter) → /features/schema · **Files** → /features/import-export · **Connect** → /features/connections | mac-query-autocomplete, mac-edit-preview-sql, mac-compare-sync-structure, mac-data-files-window, mac-connection-ssh-form | pricing (tier markers) | | 5 | `safety` | **Production safety** | Can I point it at production without fear? | Safe Mode per connection (levels named); DROP, TRUNCATE and WHERE-less DELETE always ask; Read-Only; Touch ID; Agent mode raises the floor → /features/data-editing#safe-mode | mac-safe-mode-touchid | facts (Safe Mode levels) | | 6 | `ai` (`mcp`) | **AI and MCP** | Does it work with AI tools, and what can the AI touch? | Bring your own provider (named); Agent mode; a local MCP server that is off by default; the permission layers in one line; free → /features/ai-mcp | mac-ai-chat | facts (providers) | diff --git a/public/images/sponsors/getapps-cafe.png b/public/images/sponsors/getapps-cafe.png deleted file mode 100644 index 8ed0b29a..00000000 Binary files a/public/images/sponsors/getapps-cafe.png and /dev/null differ diff --git a/public/images/sponsors/pyto.svg b/public/images/sponsors/pyto.svg new file mode 100644 index 00000000..3653e9c1 --- /dev/null +++ b/public/images/sponsors/pyto.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/sponsors/unikorn.svg b/public/images/sponsors/unikorn.svg deleted file mode 100644 index 2ab18197..00000000 --- a/public/images/sponsors/unikorn.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/sponsors/visnalize.svg b/public/images/sponsors/visnalize.svg deleted file mode 100644 index 21bb493f..00000000 --- a/public/images/sponsors/visnalize.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/images/sponsors/xermius.webp b/public/images/sponsors/xermius.webp deleted file mode 100644 index 7a91594d..00000000 Binary files a/public/images/sponsors/xermius.webp and /dev/null differ diff --git a/resources/data/content/de/about.json b/resources/data/content/de/about.json index 2dbd76ae..855da135 100644 --- a/resources/data/content/de/about.json +++ b/resources/data/content/de/about.json @@ -28,7 +28,7 @@ "title": "Finanzierung", "body": "Bezahlte Lizenzen und Sponsoren finanzieren TablePro. Es hat keine Investoren und verkauft keine Daten.", "plans": "Eine Starter- oder Team-Lizenz ergänzt die Mac-App um optionale Funktionen; die Preisseite listet sie auf.", - "sponsors": "Die aktuellen Sponsoren stehen auf der Startseite. Sponsoring läuft über GitHub Sponsors." + "sponsors": "Die aktuellen Sponsoren und was jede Stufe umfasst, stehen auf der Sponsorenseite." }, "source": { "title": "Quellcode", diff --git a/resources/data/content/de/home.json b/resources/data/content/de/home.json index 50171a2f..5da7a4b1 100644 --- a/resources/data/content/de/home.json +++ b/resources/data/content/de/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Sponsoren", - "link": "TablePro unterstützen" + "link": "TablePro unterstützen", + "invite": { + "title": "Dein Logo hier", + "open": { + "one": "{count} von {total} frei", + "other": "{count} von {total} frei" + } + } }, "workflows": { "title": "Mit deinen Datenbanken arbeiten", diff --git a/resources/data/content/de/sponsors.json b/resources/data/content/de/sponsors.json new file mode 100644 index 00000000..3f33c3cd --- /dev/null +++ b/resources/data/content/de/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Sponsoren", + "description": "Die Unternehmen und Menschen, die TablePro sponsern, was jede Sponsoring-Stufe umfasst und wie du über GitHub Sponsors Sponsor wirst." + }, + "og": { + "kicker": "Sponsoren", + "title": "TablePro unterstützen" + }, + "breadcrumb": "Sponsoren", + "header": { + "title": "Sponsoren", + "lead": "TablePro ist Open Source. Sponsoren bezahlen die Zeit, die in Entwicklung und Pflege fließt.", + "action": "Auf GitHub sponsern" + }, + "companies": { + "title": "Unternehmen", + "open": { + "one": "{count} von {total} Logoplätzen auf der Startseite ist frei.", + "other": "{count} von {total} Logoplätzen auf der Startseite sind frei." + }, + "full": "Auf der Startseite ist keiner der {total} Logoplätze mehr frei." + }, + "people": { + "title": "Einzelpersonen" + }, + "tiers": { + "title": "Stufen", + "lead": { + "one": "Sponsere monatlich oder einmalig für {count} Monat mit derselben Platzierung.", + "other": "Sponsere monatlich oder einmalig für {count} Monate mit derselben Platzierung." + }, + "caption": "Sponsoring-Stufen mit monatlichem Preis, einmaligem Preis und dem, was jede Stufe umfasst", + "columns": { + "monthly": "Monatlich", + "oneTime": "Einmalig", + "benefit": "Das bekommst du" + }, + "oneTimePrice": "{price} einmalig", + "benefit": { + "name": "Dein Name mit Link auf dieser Seite und in der README von TablePro.", + "homepage": "Dein Logo mit Link auf der Startseite, auf dieser Seite und in der README von TablePro. Die Startseite bietet Platz für {total}.", + "lead": "Dein Logo zuerst und größer auf der Startseite, auf dieser Seite und in der README." + }, + "link": "TablePro auf GitHub sponsern" + } +} diff --git a/resources/data/content/en/about.json b/resources/data/content/en/about.json index 4005520a..de26072d 100644 --- a/resources/data/content/en/about.json +++ b/resources/data/content/en/about.json @@ -28,7 +28,7 @@ "title": "How it is funded", "body": "Paid licenses and sponsors fund TablePro. It has no investors and sells no data.", "plans": "A Starter or Team license adds optional features to the Mac app; the pricing page lists them.", - "sponsors": "The current sponsors are listed on the homepage. Sponsorship goes through GitHub Sponsors." + "sponsors": "The current sponsors, and what each sponsorship includes, are on the sponsors page." }, "source": { "title": "Source code", diff --git a/resources/data/content/en/home.json b/resources/data/content/en/home.json index 895fdc8e..e4a87396 100644 --- a/resources/data/content/en/home.json +++ b/resources/data/content/en/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Sponsors", - "link": "Sponsor TablePro" + "link": "Sponsor TablePro", + "invite": { + "title": "Your logo here", + "open": { + "one": "{count} of {total} open", + "other": "{count} of {total} open" + } + } }, "workflows": { "title": "Working with your databases", diff --git a/resources/data/content/en/sponsors.json b/resources/data/content/en/sponsors.json new file mode 100644 index 00000000..0cf28a77 --- /dev/null +++ b/resources/data/content/en/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Sponsors", + "description": "The companies and people who sponsor TablePro, what each sponsorship tier includes, and how to become a sponsor through GitHub Sponsors." + }, + "og": { + "kicker": "Sponsors", + "title": "Sponsor TablePro" + }, + "breadcrumb": "Sponsors", + "header": { + "title": "Sponsors", + "lead": "TablePro is open source. Sponsors pay for the time that goes into building and maintaining it.", + "action": "Sponsor on GitHub" + }, + "companies": { + "title": "Companies", + "open": { + "one": "{count} of the {total} places on the homepage is open.", + "other": "{count} of the {total} places on the homepage are open." + }, + "full": "All {total} places on the homepage are taken." + }, + "people": { + "title": "Individuals" + }, + "tiers": { + "title": "Tiers", + "lead": { + "one": "Sponsor monthly, or once for {count} month of the same placement.", + "other": "Sponsor monthly, or once for {count} months of the same placement." + }, + "caption": "Sponsorship tiers with the monthly price, the one-time price and what each includes", + "columns": { + "monthly": "Monthly", + "oneTime": "One time", + "benefit": "What you get" + }, + "oneTimePrice": "{price} one time", + "benefit": { + "name": "Your name and link on this page and in the TablePro README.", + "homepage": "Your logo and link on the homepage, this page and the TablePro README. The homepage has room for {total}.", + "lead": "Your logo first and larger on the homepage, this page and the README." + }, + "link": "Sponsor TablePro on GitHub" + } +} diff --git a/resources/data/content/es/about.json b/resources/data/content/es/about.json index 77f5dcb5..1f891f44 100644 --- a/resources/data/content/es/about.json +++ b/resources/data/content/es/about.json @@ -28,7 +28,7 @@ "title": "Financiación", "body": "Las licencias de pago y los patrocinadores financian TablePro. No tiene inversores ni vende datos.", "plans": "Una licencia Starter o Team añade funciones opcionales a la app para Mac; la página de precios las enumera.", - "sponsors": "Los patrocinadores actuales aparecen en la página de inicio. El patrocinio se gestiona a través de GitHub Sponsors." + "sponsors": "Los patrocinadores actuales y lo que incluye cada nivel aparecen en la página de patrocinadores." }, "source": { "title": "Código fuente", diff --git a/resources/data/content/es/home.json b/resources/data/content/es/home.json index cbcb6eeb..12f61166 100644 --- a/resources/data/content/es/home.json +++ b/resources/data/content/es/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Patrocinadores", - "link": "Patrocinar TablePro" + "link": "Patrocinar TablePro", + "invite": { + "title": "Tu logo aquí", + "open": { + "one": "{count} de {total} libre", + "other": "{count} de {total} libres" + } + } }, "workflows": { "title": "Trabaja con tus bases de datos", diff --git a/resources/data/content/es/sponsors.json b/resources/data/content/es/sponsors.json new file mode 100644 index 00000000..cf24d47d --- /dev/null +++ b/resources/data/content/es/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Patrocinadores", + "description": "Las empresas y personas que patrocinan TablePro, qué incluye cada nivel de patrocinio y cómo patrocinarlo a través de GitHub Sponsors." + }, + "og": { + "kicker": "Patrocinadores", + "title": "Patrocinar TablePro" + }, + "breadcrumb": "Patrocinadores", + "header": { + "title": "Patrocinadores", + "lead": "TablePro es de código abierto. Los patrocinadores pagan el tiempo que se dedica a desarrollarlo y mantenerlo.", + "action": "Patrocinar en GitHub" + }, + "companies": { + "title": "Empresas", + "open": { + "one": "Queda {count} de {total} espacios en la página de inicio.", + "other": "Quedan {count} de {total} espacios en la página de inicio." + }, + "full": "Los {total} espacios de la página de inicio están ocupados." + }, + "people": { + "title": "Personas" + }, + "tiers": { + "title": "Niveles", + "lead": { + "one": "Patrocina cada mes, o una sola vez por {count} mes con la misma ubicación.", + "other": "Patrocina cada mes, o una sola vez por {count} meses con la misma ubicación." + }, + "caption": "Niveles de patrocinio con el precio mensual, el precio único y lo que incluye cada uno", + "columns": { + "monthly": "Mensual", + "oneTime": "Una vez", + "benefit": "Qué incluye" + }, + "oneTimePrice": "{price} una vez", + "benefit": { + "name": "Tu nombre y un enlace en esta página y en el README de TablePro.", + "homepage": "Tu logo y un enlace en la página de inicio, en esta página y en el README de TablePro. La página de inicio tiene {total} espacios.", + "lead": "Tu logo primero y más grande en la página de inicio, en esta página y en el README." + }, + "link": "Patrocinar TablePro en GitHub" + } +} diff --git a/resources/data/content/fr/about.json b/resources/data/content/fr/about.json index c14bd2a4..d655b329 100644 --- a/resources/data/content/fr/about.json +++ b/resources/data/content/fr/about.json @@ -28,7 +28,7 @@ "title": "Financement", "body": "Les licences payantes et les sponsors financent TablePro. Le projet n’a aucun investisseur et ne vend aucune donnée.", "plans": "Une licence Starter ou Team ajoute des fonctionnalités facultatives à l’application Mac, listées sur la page des tarifs.", - "sponsors": "Les sponsors actuels figurent sur la page d’accueil. Le parrainage passe par GitHub Sponsors." + "sponsors": "Les sponsors actuels et ce que comprend chaque niveau figurent sur la page des sponsors." }, "source": { "title": "Code source", diff --git a/resources/data/content/fr/home.json b/resources/data/content/fr/home.json index 6aed62d0..9bb6ca35 100644 --- a/resources/data/content/fr/home.json +++ b/resources/data/content/fr/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Sponsors", - "link": "Soutenir TablePro" + "link": "Soutenir TablePro", + "invite": { + "title": "Votre logo ici", + "open": { + "one": "{count} place libre sur {total}", + "other": "{count} places libres sur {total}" + } + } }, "workflows": { "title": "Travailler avec vos bases de données", diff --git a/resources/data/content/fr/sponsors.json b/resources/data/content/fr/sponsors.json new file mode 100644 index 00000000..c6145adb --- /dev/null +++ b/resources/data/content/fr/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Sponsors", + "description": "Les entreprises et les personnes qui sponsorisent TablePro, ce que comprend chaque niveau de parrainage et comment devenir sponsor via GitHub Sponsors." + }, + "og": { + "kicker": "Sponsors", + "title": "Soutenir TablePro" + }, + "breadcrumb": "Sponsors", + "header": { + "title": "Sponsors", + "lead": "TablePro est open source. Les sponsors financent le temps consacré à son développement et à sa maintenance.", + "action": "Sponsoriser sur GitHub" + }, + "companies": { + "title": "Entreprises", + "open": { + "one": "{count} place sur {total} est libre sur la page d’accueil.", + "other": "{count} places sur {total} sont libres sur la page d’accueil." + }, + "full": "Les {total} places de la page d’accueil sont prises." + }, + "people": { + "title": "Particuliers" + }, + "tiers": { + "title": "Niveaux", + "lead": { + "one": "Sponsorisez chaque mois, ou une seule fois pour {count} mois au même emplacement.", + "other": "Sponsorisez chaque mois, ou une seule fois pour {count} mois au même emplacement." + }, + "caption": "Niveaux de parrainage avec le prix mensuel, le prix unique et ce que comprend chacun", + "columns": { + "monthly": "Mensuel", + "oneTime": "Une fois", + "benefit": "Ce que vous obtenez" + }, + "oneTimePrice": "{price} une fois", + "benefit": { + "name": "Votre nom et un lien sur cette page et dans le README de TablePro.", + "homepage": "Votre logo et un lien sur la page d’accueil, sur cette page et dans le README de TablePro. La page d’accueil compte {total} places.", + "lead": "Votre logo en premier et en plus grand sur la page d’accueil, cette page et le README." + }, + "link": "Sponsoriser TablePro sur GitHub" + } +} diff --git a/resources/data/content/id/about.json b/resources/data/content/id/about.json index dc4bd534..b7260913 100644 --- a/resources/data/content/id/about.json +++ b/resources/data/content/id/about.json @@ -28,7 +28,7 @@ "title": "Pendanaan", "body": "Lisensi berbayar dan sponsor mendanai TablePro. Proyek ini tidak memiliki investor dan tidak menjual data.", "plans": "Lisensi Starter atau Team menambahkan fitur opsional ke aplikasi Mac; halaman harga mencantumkannya.", - "sponsors": "Sponsor saat ini tercantum di beranda. Anda dapat menjadi sponsor melalui GitHub Sponsors." + "sponsors": "Sponsor saat ini dan isi setiap tingkatan ada di halaman sponsor." }, "source": { "title": "Kode sumber", diff --git a/resources/data/content/id/home.json b/resources/data/content/id/home.json index f0b282b2..57e7faaf 100644 --- a/resources/data/content/id/home.json +++ b/resources/data/content/id/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Sponsor", - "link": "Sponsori TablePro" + "link": "Sponsori TablePro", + "invite": { + "title": "Logo Anda di sini", + "open": { + "one": "{count} dari {total} tersedia", + "other": "{count} dari {total} tersedia" + } + } }, "workflows": { "title": "Bekerja dengan database Anda", diff --git a/resources/data/content/id/sponsors.json b/resources/data/content/id/sponsors.json new file mode 100644 index 00000000..8138fa48 --- /dev/null +++ b/resources/data/content/id/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Sponsor", + "description": "Perusahaan dan orang yang mensponsori TablePro, isi setiap tingkatan sponsor, dan cara menjadi sponsor melalui GitHub Sponsors." + }, + "og": { + "kicker": "Sponsor", + "title": "Sponsori TablePro" + }, + "breadcrumb": "Sponsor", + "header": { + "title": "Sponsor", + "lead": "TablePro adalah open source. Sponsor membayar waktu yang dicurahkan untuk mengembangkan dan merawatnya.", + "action": "Sponsori di GitHub" + }, + "companies": { + "title": "Perusahaan", + "open": { + "one": "{count} dari {total} tempat di beranda masih tersedia.", + "other": "{count} dari {total} tempat di beranda masih tersedia." + }, + "full": "Semua {total} tempat di beranda sudah terisi." + }, + "people": { + "title": "Perorangan" + }, + "tiers": { + "title": "Tingkatan", + "lead": { + "one": "Sponsori setiap bulan, atau sekali untuk {count} bulan dengan penempatan yang sama.", + "other": "Sponsori setiap bulan, atau sekali untuk {count} bulan dengan penempatan yang sama." + }, + "caption": "Tingkatan sponsor dengan harga bulanan, harga sekali bayar, dan isi setiap tingkatan", + "columns": { + "monthly": "Bulanan", + "oneTime": "Sekali", + "benefit": "Yang Anda dapatkan" + }, + "oneTimePrice": "{price} sekali", + "benefit": { + "name": "Nama dan tautan Anda di halaman ini dan di README TablePro.", + "homepage": "Logo dan tautan Anda di beranda, halaman ini, dan README TablePro. Beranda memuat {total} tempat.", + "lead": "Logo Anda paling depan dan lebih besar di beranda, halaman ini, dan README." + }, + "link": "Sponsori TablePro di GitHub" + } +} diff --git a/resources/data/content/it/about.json b/resources/data/content/it/about.json index 1a6c2fff..fd95e2a8 100644 --- a/resources/data/content/it/about.json +++ b/resources/data/content/it/about.json @@ -28,7 +28,7 @@ "title": "Finanziamento", "body": "Le licenze a pagamento e gli sponsor finanziano TablePro. Il progetto non ha investitori e non vende dati.", "plans": "Una licenza Starter o Team aggiunge funzionalità opzionali all’app per Mac; la pagina dei prezzi le elenca.", - "sponsors": "Gli sponsor attuali sono elencati nella home page. Le sponsorizzazioni passano da GitHub Sponsors." + "sponsors": "Gli sponsor attuali e cosa include ogni livello sono nella pagina degli sponsor." }, "source": { "title": "Codice sorgente", diff --git a/resources/data/content/it/home.json b/resources/data/content/it/home.json index 4accf253..fbb3fb38 100644 --- a/resources/data/content/it/home.json +++ b/resources/data/content/it/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Sponsor", - "link": "Sostieni TablePro" + "link": "Sostieni TablePro", + "invite": { + "title": "Il tuo logo qui", + "open": { + "one": "{count} posto libero su {total}", + "other": "{count} posti liberi su {total}" + } + } }, "workflows": { "title": "Lavorare con i database", diff --git a/resources/data/content/it/sponsors.json b/resources/data/content/it/sponsors.json new file mode 100644 index 00000000..9db9d611 --- /dev/null +++ b/resources/data/content/it/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Sponsor", + "description": "Le aziende e le persone che sponsorizzano TablePro, cosa include ogni livello di sponsorizzazione e come diventare sponsor tramite GitHub Sponsors." + }, + "og": { + "kicker": "Sponsor", + "title": "Sostieni TablePro" + }, + "breadcrumb": "Sponsor", + "header": { + "title": "Sponsor", + "lead": "TablePro è open source. Gli sponsor pagano il tempo dedicato a svilupparlo e mantenerlo.", + "action": "Sponsorizza su GitHub" + }, + "companies": { + "title": "Aziende", + "open": { + "one": "{count} posto su {total} è libero nella home page.", + "other": "{count} posti su {total} sono liberi nella home page." + }, + "full": "Tutti i {total} posti nella home page sono occupati." + }, + "people": { + "title": "Persone" + }, + "tiers": { + "title": "Livelli", + "lead": { + "one": "Sponsorizza ogni mese, oppure una volta sola per {count} mese con lo stesso posizionamento.", + "other": "Sponsorizza ogni mese, oppure una volta sola per {count} mesi con lo stesso posizionamento." + }, + "caption": "Livelli di sponsorizzazione con il prezzo mensile, il prezzo una tantum e cosa include ciascuno", + "columns": { + "monthly": "Mensile", + "oneTime": "Una tantum", + "benefit": "Cosa include" + }, + "oneTimePrice": "{price} una tantum", + "benefit": { + "name": "Il tuo nome con un link in questa pagina e nel README di TablePro.", + "homepage": "Il tuo logo con un link nella home page, in questa pagina e nel README di TablePro. La home page ha {total} posti.", + "lead": "Il tuo logo per primo e più grande nella home page, in questa pagina e nel README." + }, + "link": "Sponsorizza TablePro su GitHub" + } +} diff --git a/resources/data/content/ja/about.json b/resources/data/content/ja/about.json index c18a7e43..43dcd7e1 100644 --- a/resources/data/content/ja/about.json +++ b/resources/data/content/ja/about.json @@ -28,7 +28,7 @@ "title": "開発資金", "body": "TablePro は有料ライセンスとスポンサーの支援で開発されています。投資家はおらず、データの販売もしません。", "plans": "Starter または Team ライセンスで Mac アプリに任意の機能を追加できます。料金ページに一覧があります。", - "sponsors": "現在のスポンサーはトップページに掲載されています。スポンサーには GitHub Sponsors から登録できます。" + "sponsors": "現在のスポンサーと各ティアの内容はスポンサーページに掲載しています。" }, "source": { "title": "ソースコード", diff --git a/resources/data/content/ja/home.json b/resources/data/content/ja/home.json index 06b25ab8..e27b5022 100644 --- a/resources/data/content/ja/home.json +++ b/resources/data/content/ja/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "スポンサー", - "link": "TablePro を支援" + "link": "TablePro を支援", + "invite": { + "title": "ここにロゴを掲載", + "open": { + "one": "空き {count} / {total} 枠", + "other": "空き {count} / {total} 枠" + } + } }, "workflows": { "title": "データベースでできること", diff --git a/resources/data/content/ja/sponsors.json b/resources/data/content/ja/sponsors.json new file mode 100644 index 00000000..b8692c7a --- /dev/null +++ b/resources/data/content/ja/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "スポンサー", + "description": "TablePro を支援する企業と個人、各スポンサーティアの内容、GitHub Sponsors でスポンサーになる方法を紹介します。" + }, + "og": { + "kicker": "スポンサー", + "title": "TablePro を支援" + }, + "breadcrumb": "スポンサー", + "header": { + "title": "スポンサー", + "lead": "TablePro はオープンソースです。スポンサーの支援が、開発と保守に費やす時間を支えています。", + "action": "GitHub でスポンサーになる" + }, + "companies": { + "title": "企業", + "open": { + "one": "トップページの {total} 枠のうち {count} 枠が空いています。", + "other": "トップページの {total} 枠のうち {count} 枠が空いています。" + }, + "full": "トップページの {total} 枠はすべて埋まっています。" + }, + "people": { + "title": "個人" + }, + "tiers": { + "title": "ティア", + "lead": { + "one": "毎月のスポンサーのほか、一括払いで同じ掲載を {count} か月受けられます。", + "other": "毎月のスポンサーのほか、一括払いで同じ掲載を {count} か月受けられます。" + }, + "caption": "スポンサーティアの月額、一括払いの金額、各ティアの内容", + "columns": { + "monthly": "月額", + "oneTime": "一括", + "benefit": "内容" + }, + "oneTimePrice": "一括 {price}", + "benefit": { + "name": "このページと TablePro の README に名前とリンクを掲載。", + "homepage": "トップページ、このページ、TablePro の README にロゴとリンクを掲載。トップページの掲載枠は {total} 枠です。", + "lead": "トップページ、このページ、README でロゴを先頭に大きく掲載。" + }, + "link": "GitHub で TablePro を支援" + } +} diff --git a/resources/data/content/ko/about.json b/resources/data/content/ko/about.json index 0a84962f..31a7df3d 100644 --- a/resources/data/content/ko/about.json +++ b/resources/data/content/ko/about.json @@ -28,7 +28,7 @@ "title": "개발 자금", "body": "TablePro는 유료 라이선스와 후원으로 운영됩니다. 투자자가 없으며 데이터를 판매하지 않습니다.", "plans": "Starter 또는 Team 라이선스는 Mac 앱에 선택 기능을 추가하며 요금 페이지에 목록이 있습니다.", - "sponsors": "현재 후원자는 홈페이지에서 볼 수 있습니다. 후원은 GitHub Sponsors를 통해 이루어집니다." + "sponsors": "현재 후원자와 각 등급의 혜택은 후원 페이지에서 볼 수 있습니다." }, "source": { "title": "소스 코드", diff --git a/resources/data/content/ko/home.json b/resources/data/content/ko/home.json index 11dfc2a9..12a000a5 100644 --- a/resources/data/content/ko/home.json +++ b/resources/data/content/ko/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "후원자", - "link": "TablePro 후원" + "link": "TablePro 후원", + "invite": { + "title": "여기에 로고를 게재하세요", + "open": { + "one": "{total}개 중 {count}개 남음", + "other": "{total}개 중 {count}개 남음" + } + } }, "workflows": { "title": "데이터베이스 작업", diff --git a/resources/data/content/ko/sponsors.json b/resources/data/content/ko/sponsors.json new file mode 100644 index 00000000..23ff33a2 --- /dev/null +++ b/resources/data/content/ko/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "후원자", + "description": "TablePro를 후원하는 기업과 개인, 후원 등급별 혜택, GitHub Sponsors로 후원하는 방법을 안내합니다." + }, + "og": { + "kicker": "후원자", + "title": "TablePro 후원" + }, + "breadcrumb": "후원자", + "header": { + "title": "후원자", + "lead": "TablePro는 오픈 소스입니다. 후원금은 개발과 유지 보수에 드는 시간을 지원합니다.", + "action": "GitHub에서 후원하기" + }, + "companies": { + "title": "기업", + "open": { + "one": "홈페이지의 {total}개 자리 중 {count}개가 비어 있습니다.", + "other": "홈페이지의 {total}개 자리 중 {count}개가 비어 있습니다." + }, + "full": "홈페이지의 {total}개 자리가 모두 찼습니다." + }, + "people": { + "title": "개인" + }, + "tiers": { + "title": "후원 등급", + "lead": { + "one": "매월 후원하거나, 한 번 결제로 같은 혜택을 {count}개월 동안 받을 수 있습니다.", + "other": "매월 후원하거나, 한 번 결제로 같은 혜택을 {count}개월 동안 받을 수 있습니다." + }, + "caption": "후원 등급별 월 금액, 일시불 금액, 혜택", + "columns": { + "monthly": "월", + "oneTime": "일시불", + "benefit": "혜택" + }, + "oneTimePrice": "일시불 {price}", + "benefit": { + "name": "이 페이지와 TablePro README에 이름과 링크를 게재합니다.", + "homepage": "홈페이지, 이 페이지, TablePro README에 로고와 링크를 게재합니다. 홈페이지에는 {total}개 자리가 있습니다.", + "lead": "홈페이지, 이 페이지, README에서 로고를 맨 앞에 더 크게 게재합니다." + }, + "link": "GitHub에서 TablePro 후원하기" + } +} diff --git a/resources/data/content/pt-BR/about.json b/resources/data/content/pt-BR/about.json index ad6b6fbc..af7c8662 100644 --- a/resources/data/content/pt-BR/about.json +++ b/resources/data/content/pt-BR/about.json @@ -28,7 +28,7 @@ "title": "Financiamento", "body": "Licenças pagas e patrocinadores financiam o TablePro. O projeto não tem investidores e não vende dados.", "plans": "Uma licença Starter ou Team adiciona recursos opcionais ao app para Mac; a página de preços os lista.", - "sponsors": "Os patrocinadores atuais aparecem na página inicial. O patrocínio é feito pelo GitHub Sponsors." + "sponsors": "Os patrocinadores atuais e o que cada nível inclui estão na página de patrocinadores." }, "source": { "title": "Código-fonte", diff --git a/resources/data/content/pt-BR/home.json b/resources/data/content/pt-BR/home.json index f8a2bd79..ab0b6e5d 100644 --- a/resources/data/content/pt-BR/home.json +++ b/resources/data/content/pt-BR/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Patrocinadores", - "link": "Patrocinar TablePro" + "link": "Patrocinar TablePro", + "invite": { + "title": "Seu logo aqui", + "open": { + "one": "{count} de {total} livre", + "other": "{count} de {total} livres" + } + } }, "workflows": { "title": "Trabalhando com seus bancos de dados", diff --git a/resources/data/content/pt-BR/sponsors.json b/resources/data/content/pt-BR/sponsors.json new file mode 100644 index 00000000..cb9807b1 --- /dev/null +++ b/resources/data/content/pt-BR/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Patrocinadores", + "description": "As empresas e pessoas que patrocinam TablePro, o que cada nível de patrocínio inclui e como patrocinar pelo GitHub Sponsors." + }, + "og": { + "kicker": "Patrocinadores", + "title": "Patrocinar TablePro" + }, + "breadcrumb": "Patrocinadores", + "header": { + "title": "Patrocinadores", + "lead": "TablePro é de código aberto. Os patrocinadores pagam o tempo dedicado ao desenvolvimento e à manutenção.", + "action": "Patrocinar no GitHub" + }, + "companies": { + "title": "Empresas", + "open": { + "one": "{count} de {total} espaços na página inicial está livre.", + "other": "{count} de {total} espaços na página inicial estão livres." + }, + "full": "Os {total} espaços da página inicial estão ocupados." + }, + "people": { + "title": "Pessoas" + }, + "tiers": { + "title": "Níveis", + "lead": { + "one": "Patrocine todo mês, ou uma única vez por {count} mês com o mesmo posicionamento.", + "other": "Patrocine todo mês, ou uma única vez por {count} meses com o mesmo posicionamento." + }, + "caption": "Níveis de patrocínio com o preço mensal, o preço único e o que cada um inclui", + "columns": { + "monthly": "Mensal", + "oneTime": "Uma vez", + "benefit": "O que inclui" + }, + "oneTimePrice": "{price} uma vez", + "benefit": { + "name": "Seu nome e um link nesta página e no README do TablePro.", + "homepage": "Seu logo e um link na página inicial, nesta página e no README do TablePro. A página inicial tem {total} espaços.", + "lead": "Seu logo primeiro e maior na página inicial, nesta página e no README." + }, + "link": "Patrocinar TablePro no GitHub" + } +} diff --git a/resources/data/content/vi/about.json b/resources/data/content/vi/about.json index e2257268..46665522 100644 --- a/resources/data/content/vi/about.json +++ b/resources/data/content/vi/about.json @@ -28,7 +28,7 @@ "title": "Nguồn kinh phí", "body": "Tiền bán license và tiền tài trợ là nguồn kinh phí của TablePro. TablePro không có nhà đầu tư và không bán dữ liệu.", "plans": "License Starter hoặc Team bổ sung một số tính năng tùy chọn cho ứng dụng Mac; trang bảng giá liệt kê các tính năng này.", - "sponsors": "Các nhà tài trợ hiện tại được liệt kê trên trang chủ. Bạn có thể tài trợ qua GitHub Sponsors." + "sponsors": "Các nhà tài trợ hiện tại và quyền lợi của từng mức tài trợ có trên trang nhà tài trợ." }, "source": { "title": "Mã nguồn", diff --git a/resources/data/content/vi/home.json b/resources/data/content/vi/home.json index 7a0fd788..be25c92b 100644 --- a/resources/data/content/vi/home.json +++ b/resources/data/content/vi/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "Nhà tài trợ", - "link": "Tài trợ TablePro" + "link": "Tài trợ TablePro", + "invite": { + "title": "Logo của bạn ở đây", + "open": { + "one": "Còn trống {count}/{total} chỗ", + "other": "Còn trống {count}/{total} chỗ" + } + } }, "workflows": { "title": "Làm việc với cơ sở dữ liệu", diff --git a/resources/data/content/vi/sponsors.json b/resources/data/content/vi/sponsors.json new file mode 100644 index 00000000..aeae4bf4 --- /dev/null +++ b/resources/data/content/vi/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "Nhà tài trợ", + "description": "Các công ty và cá nhân tài trợ TablePro, quyền lợi của từng mức tài trợ và cách tài trợ qua GitHub Sponsors." + }, + "og": { + "kicker": "Nhà tài trợ", + "title": "Tài trợ TablePro" + }, + "breadcrumb": "Nhà tài trợ", + "header": { + "title": "Nhà tài trợ", + "lead": "TablePro là phần mềm mã nguồn mở. Tiền tài trợ trả cho thời gian phát triển và bảo trì TablePro.", + "action": "Tài trợ trên GitHub" + }, + "companies": { + "title": "Công ty", + "open": { + "one": "Trang chủ còn trống {count} trong số {total} chỗ.", + "other": "Trang chủ còn trống {count} trong số {total} chỗ." + }, + "full": "Trang chủ đã kín cả {total} chỗ." + }, + "people": { + "title": "Cá nhân" + }, + "tiers": { + "title": "Các mức tài trợ", + "lead": { + "one": "Bạn có thể tài trợ theo tháng, hoặc tài trợ một lần để giữ cùng vị trí trong {count} tháng.", + "other": "Bạn có thể tài trợ theo tháng, hoặc tài trợ một lần để giữ cùng vị trí trong {count} tháng." + }, + "caption": "Các mức tài trợ với giá hằng tháng, giá một lần và quyền lợi của từng mức", + "columns": { + "monthly": "Theo tháng", + "oneTime": "Một lần", + "benefit": "Quyền lợi" + }, + "oneTimePrice": "{price} một lần", + "benefit": { + "name": "Tên và link của bạn trên trang này và trong README của TablePro.", + "homepage": "Logo và link của bạn trên trang chủ, trang này và README của TablePro. Trang chủ có {total} chỗ.", + "lead": "Logo của bạn đứng đầu và lớn hơn trên trang chủ, trang này và README." + }, + "link": "Tài trợ TablePro trên GitHub" + } +} diff --git a/resources/data/content/zh-Hans/about.json b/resources/data/content/zh-Hans/about.json index e168aefb..37d546b3 100644 --- a/resources/data/content/zh-Hans/about.json +++ b/resources/data/content/zh-Hans/about.json @@ -28,7 +28,7 @@ "title": "资金来源", "body": "TablePro 的资金来自付费许可证和赞助商。TablePro 没有投资者,也不出售数据。", "plans": "Starter 或 Team 许可证可为 Mac 应用增加可选功能,定价页面列出了这些功能。", - "sponsors": "当前的赞助商列在首页上。赞助通过 GitHub Sponsors 进行。" + "sponsors": "当前的赞助商和各档位的权益列在赞助页面上。" }, "source": { "title": "源代码", diff --git a/resources/data/content/zh-Hans/home.json b/resources/data/content/zh-Hans/home.json index 37e6ea93..0c9207a0 100644 --- a/resources/data/content/zh-Hans/home.json +++ b/resources/data/content/zh-Hans/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "赞助商", - "link": "赞助 TablePro" + "link": "赞助 TablePro", + "invite": { + "title": "在此展示徽标", + "open": { + "one": "剩余 {count}/{total} 个位置", + "other": "剩余 {count}/{total} 个位置" + } + } }, "workflows": { "title": "数据库操作", diff --git a/resources/data/content/zh-Hans/sponsors.json b/resources/data/content/zh-Hans/sponsors.json new file mode 100644 index 00000000..1e9b2674 --- /dev/null +++ b/resources/data/content/zh-Hans/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "赞助商", + "description": "赞助 TablePro 的公司和个人、各赞助档位的权益,以及如何通过 GitHub Sponsors 成为赞助商。" + }, + "og": { + "kicker": "赞助商", + "title": "赞助 TablePro" + }, + "breadcrumb": "赞助商", + "header": { + "title": "赞助商", + "lead": "TablePro 是开源软件。赞助用于支付开发和维护所投入的时间。", + "action": "在 GitHub 上赞助" + }, + "companies": { + "title": "公司", + "open": { + "one": "首页共 {total} 个位置,还剩 {count} 个。", + "other": "首页共 {total} 个位置,还剩 {count} 个。" + }, + "full": "首页的 {total} 个位置已全部占满。" + }, + "people": { + "title": "个人" + }, + "tiers": { + "title": "赞助档位", + "lead": { + "one": "可以按月赞助,也可以一次性付款,在 {count} 个月内获得相同的展示位置。", + "other": "可以按月赞助,也可以一次性付款,在 {count} 个月内获得相同的展示位置。" + }, + "caption": "各赞助档位的月付价格、一次性价格和权益", + "columns": { + "monthly": "月付", + "oneTime": "一次性", + "benefit": "权益" + }, + "oneTimePrice": "一次性 {price}", + "benefit": { + "name": "在本页和 TablePro 的 README 中展示名字和链接。", + "homepage": "在首页、本页和 TablePro 的 README 中展示徽标和链接。首页共 {total} 个位置。", + "lead": "在首页、本页和 README 中,徽标排在最前并以更大尺寸展示。" + }, + "link": "在 GitHub 上赞助 TablePro" + } +} diff --git a/resources/data/content/zh-Hant/about.json b/resources/data/content/zh-Hant/about.json index 2f97f756..46028c30 100644 --- a/resources/data/content/zh-Hant/about.json +++ b/resources/data/content/zh-Hant/about.json @@ -28,7 +28,7 @@ "title": "資金來源", "body": "TablePro 的資金來自付費授權與贊助者。TablePro 沒有投資者,也不出售資料。", "plans": "Starter 或 Team 授權可為 Mac App 增加選用功能,價格方案頁面列出了這些功能。", - "sponsors": "目前的贊助者列在首頁上。贊助透過 GitHub Sponsors 進行。" + "sponsors": "目前的贊助者和各級別的權益列在贊助頁面上。" }, "source": { "title": "原始碼", diff --git a/resources/data/content/zh-Hant/home.json b/resources/data/content/zh-Hant/home.json index 88f88dc2..f825f3e6 100644 --- a/resources/data/content/zh-Hant/home.json +++ b/resources/data/content/zh-Hant/home.json @@ -27,7 +27,14 @@ }, "sponsors": { "title": "贊助者", - "link": "贊助 TablePro" + "link": "贊助 TablePro", + "invite": { + "title": "在此展示標誌", + "open": { + "one": "剩餘 {count}/{total} 個位置", + "other": "剩餘 {count}/{total} 個位置" + } + } }, "workflows": { "title": "資料庫操作", diff --git a/resources/data/content/zh-Hant/sponsors.json b/resources/data/content/zh-Hant/sponsors.json new file mode 100644 index 00000000..6cad36a7 --- /dev/null +++ b/resources/data/content/zh-Hant/sponsors.json @@ -0,0 +1,47 @@ +{ + "seo": { + "title": "贊助者", + "description": "贊助 TablePro 的公司和個人、各贊助級別的權益,以及如何透過 GitHub Sponsors 成為贊助者。" + }, + "og": { + "kicker": "贊助者", + "title": "贊助 TablePro" + }, + "breadcrumb": "贊助者", + "header": { + "title": "贊助者", + "lead": "TablePro 是開源軟體。贊助用於支付開發與維護所投入的時間。", + "action": "在 GitHub 上贊助" + }, + "companies": { + "title": "公司", + "open": { + "one": "首頁共 {total} 個位置,還剩 {count} 個。", + "other": "首頁共 {total} 個位置,還剩 {count} 個。" + }, + "full": "首頁的 {total} 個位置已全部額滿。" + }, + "people": { + "title": "個人" + }, + "tiers": { + "title": "贊助級別", + "lead": { + "one": "可以按月贊助,也可以一次付款,在 {count} 個月內享有相同的展示位置。", + "other": "可以按月贊助,也可以一次付款,在 {count} 個月內享有相同的展示位置。" + }, + "caption": "各贊助級別的月付價格、一次性價格與權益", + "columns": { + "monthly": "月付", + "oneTime": "一次性", + "benefit": "權益" + }, + "oneTimePrice": "一次性 {price}", + "benefit": { + "name": "在本頁和 TablePro 的 README 中展示名字和連結。", + "homepage": "在首頁、本頁和 TablePro 的 README 中展示標誌和連結。首頁共 {total} 個位置。", + "lead": "在首頁、本頁和 README 中,標誌排在最前並以更大尺寸展示。" + }, + "link": "在 GitHub 上贊助 TablePro" + } +} diff --git a/resources/data/sponsors.json b/resources/data/sponsors.json index 5d65ab89..4d1c2917 100644 --- a/resources/data/sponsors.json +++ b/resources/data/sponsors.json @@ -1,14 +1,23 @@ { - "verifiedAt": "2026-10-02", + "verifiedAt": "2026-10-29", "verification": { - "method": "GitHub GraphQL sponsorshipsAsMaintainer(activeOnly: true) for the user datlechin and the organization TableProApp", + "method": "GitHub GraphQL sponsorshipsAsMaintainer(activeOnly: true, includePrivate: true) for the user datlechin and the organization TableProApp, read with the sponsors skill", "program": "https://github.com/sponsors/datlechin" }, + "homepageSlots": 12, + "leadSlots": 1, + "oneTime": { "multiplier": 10, "months": 12 }, + "tiers": [ + { "placement": "name", "monthly": 10 }, + { "placement": "homepage", "monthly": 100 }, + { "placement": "lead", "monthly": 250 } + ], "sponsors": [ { "id": "coderabbit", "name": "CodeRabbit", "githubLogin": "coderabbitai", + "placement": "homepage", "url": "https://coderabbit.link/tablepro", "logo": { "light": "/images/sponsors/coderabbit.svg", "dark": null, "width": 2152, "height": 313 } }, @@ -16,6 +25,7 @@ "id": "simplelocalize", "name": "SimpleLocalize", "githubLogin": "simplelocalize", + "placement": "homepage", "url": "https://simplelocalize.io?ref=tablepro", "logo": { "light": "/images/sponsors/simplelocalize.svg", "dark": null, "width": 540, "height": 101 } }, @@ -23,6 +33,7 @@ "id": "nimbus", "name": "Nimbus", "githubLogin": "getnimbus", + "placement": "homepage", "url": "https://getnimbus.io?ref=tablepro", "logo": { "light": "/images/sponsors/nimbus.svg", "dark": null, "width": 430, "height": 168 } }, @@ -30,8 +41,25 @@ "id": "dwarves-foundation", "name": "Dwarves Foundation", "githubLogin": "dwarvesf", + "placement": "homepage", "url": "https://dwarves.foundation?ref=tablepro", "logo": { "light": "/images/sponsors/dwarves-foundation.png", "dark": null, "width": 112, "height": 112 } + }, + { + "id": "pyto", + "name": "Pyto", + "githubLogin": "withpyto", + "placement": "homepage", + "url": "https://pyto.com?ref=tablepro", + "logo": { "light": "/images/sponsors/pyto.svg", "dark": null, "width": 146, "height": 43 } + }, + { + "id": "zhuwjin", + "name": "朱文锦", + "githubLogin": "zhuwjin", + "placement": "name", + "url": "https://github.com/zhuwjin", + "logo": null } ] } diff --git a/resources/js/components/faq/content-links.tsx b/resources/js/components/faq/content-links.tsx index 00777bab..2a0662a1 100644 --- a/resources/js/components/faq/content-links.tsx +++ b/resources/js/components/faq/content-links.tsx @@ -31,7 +31,7 @@ export interface SiteLinks { */ const INTERNAL: Record = { home: '/', - sponsors: '/#sponsors', + sponsors: '/sponsors', features: '/features', querying: '/features/querying', connections: '/features/connections', diff --git a/resources/js/components/home/sponsors-section.tsx b/resources/js/components/home/sponsors-section.tsx index 0c4136de..2c14de74 100644 --- a/resources/js/components/home/sponsors-section.tsx +++ b/resources/js/components/home/sponsors-section.tsx @@ -1,62 +1,17 @@ -import CellGrid from '@/components/ui/cell-grid'; +import SponsorGrid, { SPONSOR_CELL, SPONSOR_LINK } from '@/components/sponsors/sponsor-grid'; +import LocaleLink from '@/components/ui/locale-link'; import Section from '@/components/ui/section'; -import TextLink from '@/components/ui/text-link'; -import { FACTS } from '@/lib/data/facts'; -import { SPONSORS, type Sponsor } from '@/lib/data/sponsors'; +import { textLinkClasses } from '@/components/ui/text-link'; +import { useI18n } from '@/i18n'; +import { SPONSORS } from '@/lib/data/sponsors'; +import { onHomepage, openSlots } from '@/lib/sponsors'; import type { HomeContent } from './types'; -/** - * Logos are drawn 28px tall (design-system §5.3.18 SponsorList); the width - * follows the file's own ratio. Two columns below 640px (a 2 × 2 grid, §8.1), - * a wrapping row above, and a wordmark wider than its column scales down - * (`max-w-full`, `object-contain`) rather than running into its neighbour. - * - * Loaded eagerly at low priority: the four files are under 35 KB together, - * and a lazy logo that arrived late left its white dark-mode tile empty, which - * read as a broken image. Low priority keeps them behind the page's own - * images, and keeps React's server render from preloading them. - */ -const LOGO_HEIGHT = 28; - -function SponsorLogo({ sponsor }: { sponsor: Sponsor }) { - const width = Math.round((LOGO_HEIGHT * sponsor.logo.width) / sponsor.logo.height); - const image = (src: string, className: string) => ( - - ); - - /* - * A sponsor's own dark logo when the data has one. Otherwise the regular - * logo sits on a white tile in dark mode, as the database marks do: no - * filter or inversion ever alters a trademark. The tile's padding is there - * in light mode too, so switching theme moves nothing. - */ - return ( - - ); -} - -/** - * Section 3, `#sponsors`. Third on the page by the owner's decision (spec §0), - * and compact, so it interrupts the explanation as little as possible. - * - * Only the sponsors verified as current are listed (resources/data/sponsors.json - * records when and how). Each link is `rel="sponsored noopener"` and named by - * the sponsor's visible name under its logo. - * - * The logos are a wall of cells that closes on the next join (design-system - * §4.7): two across on a phone, four from 768px. A fifth sponsor starts a - * second row and leaves the rest of it blank page, not a block of line colour. - */ +// Third on the page by the owner's decision, and compact, so it interrupts the explanation as little as possible. export default function SponsorsSection({ content }: { content: HomeContent['sponsors'] }) { + const { plural } = useI18n(); + const open = openSlots(SPONSORS); + return (
+ {content.link} - + + } > - - {SPONSORS.sponsors.map((sponsor) => ( -
  • - - - - {sponsor.name} + + {open > 0 && ( +
  • + + + {content.invite.title} + + + {plural(content.invite.open, open, { total: SPONSORS.homepageSlots })} - +
  • - ))} -
    + )} +
    ); } diff --git a/resources/js/components/site/site-footer.tsx b/resources/js/components/site/site-footer.tsx index 6939fb0a..9cc8448a 100644 --- a/resources/js/components/site/site-footer.tsx +++ b/resources/js/components/site/site-footer.tsx @@ -307,7 +307,9 @@ export default function SiteFooter({ newsletter = true }: { newsletter?: boolean )}
  • - {groups.community.sponsor} + + {groups.community.sponsor} +
  • diff --git a/resources/js/components/site/site-links.ts b/resources/js/components/site/site-links.ts index bc0d36ff..43314dea 100644 --- a/resources/js/components/site/site-links.ts +++ b/resources/js/components/site/site-links.ts @@ -75,7 +75,6 @@ export const EXTERNAL = { github: facts.links.github, issues: facts.links.issues, discussions: facts.links.discussions, - sponsors: facts.links.sponsorsProgram, discord: facts.links.discord, x: facts.links.x, telegram: facts.links.telegram, diff --git a/resources/js/components/sponsors/sponsor-grid.tsx b/resources/js/components/sponsors/sponsor-grid.tsx new file mode 100644 index 00000000..3d0a8db9 --- /dev/null +++ b/resources/js/components/sponsors/sponsor-grid.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react'; +import CellGrid from '@/components/ui/cell-grid'; +import type { LogoSponsor, SponsorLogo } from '@/lib/data/sponsors'; +import { cn } from '@/lib/utils'; + +// tests/Feature/Data/SponsorsDataTest.php reads both heights to keep raster logos near the size they are drawn at. +const LOGO_HEIGHT = 28; +const LEAD_LOGO_HEIGHT = 44; + +// Eager at low priority: a lazy logo that arrived late left its white dark-mode tile empty, which read as a broken image. +function Logo({ logo, lead }: { logo: SponsorLogo; lead: boolean }) { + const height = lead ? LEAD_LOGO_HEIGHT : LOGO_HEIGHT; + const width = Math.round((height * logo.width) / logo.height); + const image = (src: string, className: string) => ( + + ); + + // No filter ever alters a trademark: without its own dark file, a logo sits on a white tile, padded in both themes so nothing moves. + return ( + + ); +} + +// Shared with the homepage's invite cell, so it lines up with the logos. +export const SPONSOR_CELL = 'min-w-0 max-w-full'; +export const SPONSOR_LINK = 'group grid max-w-full justify-items-start gap-2 rounded-control'; + +// Two across on a phone, four from 768px. The lead spans two cells; trailing children (an invite) share the grid's lines. +export default function SponsorGrid({ sponsors, children }: { sponsors: LogoSponsor[]; children?: ReactNode }) { + return ( + + {sponsors.map((sponsor) => { + const lead = sponsor.placement === 'lead'; + + return ( +
  • + + + + {sponsor.name} + + +
  • + ); + })} + {children} +
    + ); +} diff --git a/resources/js/lib/attribution.ts b/resources/js/lib/attribution.ts index 9b582b6a..951360c4 100644 --- a/resources/js/lib/attribution.ts +++ b/resources/js/lib/attribution.ts @@ -144,7 +144,7 @@ export function readVisit(visit: Visit): Attribution | null { /* * `ref` as a fallback for `utm_source`, because that is the shape the - * neighbourhood uses: every link in `sponsor-row.tsx` goes out as + * neighbourhood uses: the sponsor links in `sponsors.json` go out as * `?ref=tablepro`, and the sites carrying a link back send it the same way. * Only consulted when there is no utm_source, so a properly tagged link is * never second-guessed. diff --git a/resources/js/lib/data/sponsors.ts b/resources/js/lib/data/sponsors.ts index 66d13d69..808f738c 100644 --- a/resources/js/lib/data/sponsors.ts +++ b/resources/js/lib/data/sponsors.ts @@ -1,38 +1,40 @@ -/** - * resources/data/sponsors.json, typed: the sponsors verified as current on - * `verifiedAt`, in display order. - * - * The homepage renders them third, each linked with `rel="sponsored noopener"`. - * A sponsor stays listed only while its sponsorship is active; the file - * records how that was checked. - */ import data from '@data/sponsors.json'; +export type Placement = 'lead' | 'homepage' | 'name'; + export interface SponsorLogo { - /** A file under public/, used in both themes unless `dark` is set. */ + // A file under public/, used in both themes unless `dark` is set. light: string; dark: string | null; - /** Intrinsic size, so the row does not reflow while the image loads. */ width: number; height: number; } -export interface Sponsor { +interface SponsorBase { id: string; name: string; githubLogin: string; url: string; - logo: SponsorLogo; +} + +export type Sponsor = SponsorBase & ({ placement: 'name'; logo: null } | { placement: Exclude; logo: SponsorLogo }); + +export type LogoSponsor = Extract; + +export interface SponsorTier { + placement: Placement; + monthly: number; } export interface SponsorsData { verifiedAt: string; verification: { method: string; program: string }; + homepageSlots: number; + leadSlots: number; + oneTime: { multiplier: number; months: number }; + tiers: SponsorTier[]; sponsors: Sponsor[]; } -/** - * The single cast from the JSON import. `tests/Feature/Data/SponsorsDataTest.php` - * validates the file. - */ +// tests/Feature/Data/SponsorsDataTest.php validates the file this casts. export const SPONSORS = data as SponsorsData; diff --git a/resources/js/lib/sponsors.ts b/resources/js/lib/sponsors.ts new file mode 100644 index 00000000..ae8271ab --- /dev/null +++ b/resources/js/lib/sponsors.ts @@ -0,0 +1,20 @@ +import type { LogoSponsor, Placement, Sponsor, SponsorsData } from './data/sponsors.ts'; + +// No JSON import, so `node --test` loads this file directly (tests/js/sponsors.test.ts). + +function placed(data: SponsorsData, placements: Placement[]): Sponsor[] { + return placements.flatMap((placement) => data.sponsors.filter((sponsor) => sponsor.placement === placement)); +} + +export function onHomepage(data: SponsorsData): LogoSponsor[] { + return placed(data, ['lead', 'homepage']) as LogoSponsor[]; +} + +export function byName(data: SponsorsData): Sponsor[] { + return placed(data, ['name']); +} + +// Clamped, so a file with too many homepage sponsors still shows every logo it paid for. +export function openSlots(data: SponsorsData): number { + return Math.max(0, data.homepageSlots - onHomepage(data).length); +} diff --git a/resources/js/pages/Sponsors.tsx b/resources/js/pages/Sponsors.tsx new file mode 100644 index 00000000..e3854b5d --- /dev/null +++ b/resources/js/pages/Sponsors.tsx @@ -0,0 +1,137 @@ +import { usePage } from '@inertiajs/react'; +import { plainPageJsonLd } from '@/components/faq/structured-data'; +import SEOHead from '@/components/seo/seo-head'; +import SponsorGrid from '@/components/sponsors/sponsor-grid'; +import Button from '@/components/ui/button'; +import DataTable, { TABLE_CELL, TABLE_HEAD_CELL, TABLE_ROW, TABLE_ROW_HEADER } from '@/components/ui/data-table'; +import PageHeader from '@/components/ui/page-header'; +import Section from '@/components/ui/section'; +import TextLink, { textLinkClasses } from '@/components/ui/text-link'; +import { LOCALES, useI18n } from '@/i18n'; +import { FACTS, PUBLISHER } from '@/lib/data/facts'; +import { SPONSORS } from '@/lib/data/sponsors'; +import { byName, onHomepage, openSlots } from '@/lib/sponsors'; +import { cn } from '@/lib/utils'; +import LandingLayout from '@/layouts/landing-layout'; + +type SponsorsContent = typeof import('@data/content/en/sponsors.json'); + +interface SponsorsProps { + content: SponsorsContent; + organizationProfiles: string[]; +} + +// The price columns shrink to their content, so the benefit takes the rest of the row. +const PRICE = 'w-px whitespace-nowrap tabular-nums'; + +// No amounts per sponsor, no totals and no goal: what each placement costs is the tiers table. +export default function Sponsors({ content, organizationProfiles }: SponsorsProps) { + const { canonicalBaseUrl } = usePage().props; + const { locale, m, fmt, plural, path, format } = useI18n(); + const companies = onHomepage(SPONSORS); + const people = byName(SPONSORS); + const open = openSlots(SPONSORS); + const total = SPONSORS.homepageSlots; + const usd = (amount: number): string => format.formatUsd(amount, m.pricing.currency); + + const crumbs = [ + { name: m.common.home, path: path('/') }, + { name: content.breadcrumb, path: path('/sponsors') }, + ]; + + return ( + + + + + {content.header.action} + + + } + /> + +
    0 ? plural(content.companies.open, open, { total }) : fmt(content.companies.full, { total })} + flush={companies.length > 0} + > + {companies.length > 0 && } +
    + + {people.length > 0 && ( +
    + +
    + )} + +
    + {/* Below 640px the one-time price folds under the monthly one, in the row header. */} + + + + + {content.tiers.columns.monthly} + + + {content.tiers.columns.oneTime} + + + {content.tiers.columns.benefit} + + + + + {SPONSORS.tiers.map((tier) => { + const once = usd(tier.monthly * SPONSORS.oneTime.multiplier); + + return ( + + + {usd(tier.monthly)} + + {fmt(content.tiers.oneTimePrice, { price: once })} + + + {once} + {fmt(content.tiers.benefit[tier.placement], { total })} + + ); + })} + + +

    + + {content.tiers.link} + +

    +
    +
    + ); +} diff --git a/routes/localized.php b/routes/localized.php index 866b9023..6f12c69f 100644 --- a/routes/localized.php +++ b/routes/localized.php @@ -13,6 +13,7 @@ use App\Http\Controllers\LegalController; use App\Http\Controllers\PricingController; use App\Http\Controllers\SecurityController; +use App\Http\Controllers\SponsorsController; use App\Support\Content\Slugs\CompareSlugs; use App\Support\Content\Slugs\DatabaseSlugs; use App\Support\Content\Slugs\FeatureSlugs; @@ -58,6 +59,7 @@ Route::get('/faq', FaqController::class)->name('landing.faq'); Route::get('/about', AboutController::class)->name('landing.about'); Route::get('/security', SecurityController::class)->name('landing.security'); +Route::get('/sponsors', SponsorsController::class)->name('landing.sponsors'); Route::get('/privacy', [LegalController::class, 'privacy'])->name('landing.privacy'); Route::get('/terms', [LegalController::class, 'terms'])->name('landing.terms'); diff --git a/tests/Feature/Chrome/PageFrameTest.php b/tests/Feature/Chrome/PageFrameTest.php index afa75c76..33164d96 100644 --- a/tests/Feature/Chrome/PageFrameTest.php +++ b/tests/Feature/Chrome/PageFrameTest.php @@ -5,7 +5,7 @@ /** @return array */ function frameTemplates(): array { - $paths = ['/', '/vi', '/pricing', '/download', '/ios', '/faq', '/about', '/security', '/privacy', '/blog', '/blog/tablepro-0-77', '/features', '/features/querying', '/databases', '/postgresql-client', '/compare', '/compare/tableplus']; + $paths = ['/', '/vi', '/pricing', '/download', '/ios', '/faq', '/about', '/security', '/sponsors', '/privacy', '/blog', '/blog/tablepro-0-77', '/features', '/features/querying', '/databases', '/postgresql-client', '/compare', '/compare/tableplus']; return array_combine($paths, array_map(fn(string $path): array => [$path], $paths)); } diff --git a/tests/Feature/Chrome/SiteChromeTest.php b/tests/Feature/Chrome/SiteChromeTest.php index c9984d32..6bc90ae3 100644 --- a/tests/Feature/Chrome/SiteChromeTest.php +++ b/tests/Feature/Chrome/SiteChromeTest.php @@ -233,11 +233,11 @@ function chromeLocales(): array expect($titles[1])->toContain($group); } - foreach (['/features', '/databases', '/integrations', '/ios', '/pricing', '/download', '/compare', '/blog', '/faq', '/about', '/security', '/privacy', '/terms', '/refund-policy'] as $page) { + foreach (['/features', '/databases', '/integrations', '/ios', '/pricing', '/download', '/compare', '/blog', '/faq', '/about', '/sponsors', '/security', '/privacy', '/terms', '/refund-policy'] as $page) { Assert::assertNotNull(chromeLink($footer, $prefix . $page), "The footer has no link to {$prefix}{$page}"); } - foreach (['https://docs.tablepro.app', 'https://docs.tablepro.app/changelog', 'https://github.com/TableProApp/TablePro', 'https://github.com/TableProApp/TablePro/issues', 'https://github.com/sponsors/datlechin', 'mailto:hello@tablepro.app', "/account?locale={$locale}"] as $href) { + foreach (['https://docs.tablepro.app', 'https://docs.tablepro.app/changelog', 'https://github.com/TableProApp/TablePro', 'https://github.com/TableProApp/TablePro/issues', 'mailto:hello@tablepro.app', "/account?locale={$locale}"] as $href) { Assert::assertNotNull(chromeLink($footer, $href), "The footer has no link to {$href}"); } @@ -246,6 +246,9 @@ function chromeLocales(): array Assert::assertNotNull($brand, 'The footer has no link to /brand'); Assert::assertSame($vi ? 'en' : null, $brand['attrs']['hreflang'] ?? null); + // Sponsoring starts on the sponsors page, which shows the tiers before GitHub does. + expect($footer)->not->toContain('github.com/sponsors'); + // The repository is linked once, as "Source code"; Community no longer repeats it. expect(substr_count($footer, 'href="https://github.com/TableProApp/TablePro"'))->toBe(1); diff --git a/tests/Feature/Data/LocalesDataTest.php b/tests/Feature/Data/LocalesDataTest.php index 58f394d1..1779863e 100644 --- a/tests/Feature/Data/LocalesDataTest.php +++ b/tests/Feature/Data/LocalesDataTest.php @@ -47,7 +47,7 @@ function localePrefixes(): array // The platform's paths on this origin (nginx sends them to the account app). 'account', 'checkout', 'webhooks', 'newsletter', 'beta', 'discount', 'thank-you', 'api', 'platform-build', // Static directories under public/. - 'build', 'og', 'images', 'sponsors', + 'build', 'og', 'images', ]) ->unique(); diff --git a/tests/Feature/Data/SponsorsDataTest.php b/tests/Feature/Data/SponsorsDataTest.php index 93dd29f7..dee339e4 100644 --- a/tests/Feature/Data/SponsorsDataTest.php +++ b/tests/Feature/Data/SponsorsDataTest.php @@ -1,12 +1,19 @@ + * homepageSlots: int, + * leadSlots: int, + * oneTime: array{multiplier: int, months: int}, + * tiers: list, + * sponsors: list * } */ function sponsorsJson(): array @@ -39,7 +46,7 @@ function sponsorLogoSize(string $path): ?array it('records when and how the sponsors were verified', function (): void { $data = sponsorsJson(); - expect(array_keys($data))->toBe(['verifiedAt', 'verification', 'sponsors']); + expect(array_keys($data))->toBe(['verifiedAt', 'verification', 'homepageSlots', 'leadSlots', 'oneTime', 'tiers', 'sponsors']); expect($data['verifiedAt'])->toMatch('/^\d{4}-\d{2}-\d{2}$/'); expect($data['verifiedAt'] <= now()->toDateString())->toBeTrue('verifiedAt is in the future'); expect($data['verification']['method'])->toBeString()->not->toBe(''); @@ -49,20 +56,47 @@ function sponsorLogoSize(string $path): ?array expect($data['verification']['program'])->toBe($facts['links']['sponsorsProgram']); }); -it('lists exactly the four verified sponsors, in display order', function (): void { - $sponsors = sponsorsJson()['sponsors']; +it('prices one tier per placement, each above the one before', function (): void { + $data = sponsorsJson(); + + expect(array_column($data['tiers'], 'placement'))->toBe(SPONSOR_PLACEMENTS); + expect($data['homepageSlots'])->toBeInt()->toBeGreaterThan(0); + expect($data['leadSlots'])->toBeInt()->toBeGreaterThan(0)->toBeLessThanOrEqual($data['homepageSlots']); + expect(array_keys($data['oneTime']))->toBe(['multiplier', 'months']); + expect($data['oneTime']['multiplier'])->toBeInt()->toBeGreaterThan(0); + expect($data['oneTime']['months'])->toBeInt()->toBeGreaterThan(0); - expect(array_column($sponsors, 'name'))->toBe(['CodeRabbit', 'SimpleLocalize', 'Nimbus', 'Dwarves Foundation']); - expect(array_column($sponsors, 'githubLogin'))->toBe(['coderabbitai', 'simplelocalize', 'getnimbus', 'dwarvesf']); + $previous = 0; + + foreach ($data['tiers'] as $tier) { + expect(array_keys($tier))->toBe(['placement', 'monthly']); + expect($tier['monthly'])->toBeInt()->toBeGreaterThan($previous); + $previous = $tier['monthly']; + } +}); + +it('places every sponsor once, within the slots, with no amount', function (): void { + $data = sponsorsJson(); + $sponsors = $data['sponsors']; $ids = array_column($sponsors, 'id'); + $logins = array_map('strtolower', array_column($sponsors, 'githubLogin')); expect($ids)->toBe(array_values(array_unique($ids))); + expect($logins)->toBe(array_values(array_unique($logins))); foreach ($sponsors as $sponsor) { - expect(array_keys($sponsor))->toBe(['id', 'name', 'githubLogin', 'url', 'logo']); + // An amount or a tier per sponsor would publish what each one pays. + expect(array_keys($sponsor))->toBe(['id', 'name', 'githubLogin', 'placement', 'url', 'logo']); expect($sponsor['id'])->toMatch('/^[a-z0-9]+(-[a-z0-9]+)*$/'); + expect($sponsor['placement'])->toBeIn(SPONSOR_PLACEMENTS); + expect($sponsor['logo'] === null)->toBe($sponsor['placement'] === 'name', "{$sponsor['name']}: a logo goes with every placement but a name"); } + + $count = fn(array $placements): int => count(array_filter($sponsors, fn(array $sponsor): bool => in_array($sponsor['placement'], $placements, true))); + + expect($count(['lead']))->toBeLessThanOrEqual($data['leadSlots']); + Assert::assertLessThanOrEqual($data['homepageSlots'], $count(['lead', 'homepage']), 'More homepage sponsors than homepage slots'); }); it('links every sponsor over HTTPS', function (): void { @@ -72,27 +106,77 @@ function sponsorLogoSize(string $path): ?array } }); -it('points at logo files that exist, with their real intrinsic size', function (): void { +it('points at logo files named for the sponsor, with their real intrinsic size', function (): void { foreach (sponsorsJson()['sponsors'] as $sponsor) { $logo = $sponsor['logo']; + if ($logo === null) { + continue; + } + expect(array_keys($logo))->toBe(['light', 'dark', 'width', 'height']); + expect($logo['light'])->toMatch("#^/images/sponsors/{$sponsor['id']}\\.(svg|png|webp)$#"); + expect(File::exists(public_path($logo['light'])))->toBeTrue("{$logo['light']} does not exist"); + expect(sponsorLogoSize(public_path($logo['light'])))->toBe([$logo['width'], $logo['height']], "{$sponsor['name']}'s width and height must match the file"); - foreach (array_filter([$logo['light'], $logo['dark']]) as $src) { - expect($src)->toStartWith('/images/sponsors/'); - expect(File::exists(public_path($src)))->toBeTrue("{$src} does not exist"); + if ($logo['dark'] !== null) { + expect($logo['dark'])->toMatch("#^/images/sponsors/{$sponsor['id']}-dark\\.(svg|png|webp)$#"); + expect(File::exists(public_path($logo['dark'])))->toBeTrue("{$logo['dark']} does not exist"); + + // Both files share the declared size, so the dark one must keep the light one's shape. + [$width, $height] = sponsorLogoSize(public_path($logo['dark'])) ?? [0, 1]; + expect(round($width / $height, 2))->toBe(round($logo['width'] / $logo['height'], 2), "{$sponsor['name']}'s dark logo has another aspect ratio"); } + } +}); - expect(sponsorLogoSize(public_path($logo['light'])))->toBe([$logo['width'], $logo['height']], "{$sponsor['name']}'s width and height must match the file"); +it('keeps a raster logo close to the size the grid draws it at', function (): void { + $grid = File::get(resource_path('js/components/sponsors/sponsor-grid.tsx')); + + preg_match('/const LOGO_HEIGHT = (\d+);/', $grid, $regular); + preg_match('/const LEAD_LOGO_HEIGHT = (\d+);/', $grid, $lead); + + expect($regular[1] ?? null)->not->toBeNull(); + expect($lead[1] ?? null)->not->toBeNull(); + + foreach (sponsorsJson()['sponsors'] as $sponsor) { + if ($sponsor['logo'] !== null && ! str_ends_with($sponsor['logo']['light'], '.svg')) { + $shown = (int) ($sponsor['placement'] === 'lead' ? $lead[1] : $regular[1]); + + expect($sponsor['logo']['height'])->toBeLessThanOrEqual(4 * $shown, "{$sponsor['name']}'s logo is far larger than it is shown"); + } } }); -it('keeps a raster logo close to the size the wall shows it at', function (): void { - preg_match('/const LOGO_HEIGHT = (\d+);/', File::get(resource_path('js/components/home/sponsors-section.tsx')), $shown); +it('leaves no logo behind once its sponsor is gone', function (): void { + $referenced = []; foreach (sponsorsJson()['sponsors'] as $sponsor) { - if (! str_ends_with($sponsor['logo']['light'], '.svg')) { - expect($sponsor['logo']['height'])->toBeLessThanOrEqual(4 * (int) $shown[1], "{$sponsor['name']}'s logo is far larger than it is shown"); + foreach (array_filter([$sponsor['logo']['light'] ?? null, $sponsor['logo']['dark'] ?? null]) as $src) { + $referenced[] = basename($src); + } + } + + $files = array_map('basename', File::files(public_path('images/sponsors'))); + + sort($referenced); + sort($files); + + expect($files)->toBe($referenced, 'A file in public/images/sponsors belongs to no sponsor in sponsors.json'); +}); + +it('describes every tier in every language', function (string $locale): void { + $copy = json_decode(File::get(resource_path("data/content/{$locale}/sponsors.json")), true, 512, JSON_THROW_ON_ERROR); + + expect(array_keys($copy['tiers']['benefit']))->toEqualCanonicalizing(array_column(sponsorsJson()['tiers'], 'placement')); +})->with(array_keys(json_decode((string) file_get_contents(__DIR__ . '/../../../resources/data/locales.json'), true)['supported'])); + +it('reads sponsors from GitHub only on a maintainer machine, never with a token in the app', function (): void { + $paths = [app_path(), config_path(), base_path('routes'), resource_path('js'), base_path('.claude/skills/sponsors')]; + + foreach ($paths as $path) { + foreach (File::allFiles($path) as $file) { + Assert::assertDoesNotMatchRegularExpression('/GITHUB_TOKEN|GH_TOKEN|ghp_|github_pat_|gh auth token/', $file->getContents(), "{$file->getPathname()} reads a GitHub token"); } } }); diff --git a/tests/Feature/Landing/AboutPageTest.php b/tests/Feature/Landing/AboutPageTest.php index ef4c06da..bf20c4d5 100644 --- a/tests/Feature/Landing/AboutPageTest.php +++ b/tests/Feature/Landing/AboutPageTest.php @@ -152,6 +152,9 @@ function aboutVisibleText(string $html): string preg_match('##s', $html, $footer); preg_match('/[\'"]?englishOnly[\'"]?:\s*([\'"])(.*?)\1/u', File::get(resource_path("js/i18n/messages/{$locale}/common.ts")), $marker); + preg_match('#]*id="funding".*?#s', $html, $funding); + + expect($funding[0] ?? '')->toContain("href=\"{$prefix}/sponsors\"")->not->toContain('github.com/sponsors'); expect($policies[0] ?? '')->toContain("href=\"{$prefix}/security\""); expect($brand[0] ?? '')->toContain('href="/brand" hrefLang="en"'); expect(str_contains(aboutVisibleText($brand[0] ?? ''), $marker[2]))->toBe($locale !== Locales::default()); diff --git a/tests/Feature/Landing/HomepageRenderTest.php b/tests/Feature/Landing/HomepageRenderTest.php index 8a51affd..1af849b4 100644 --- a/tests/Feature/Landing/HomepageRenderTest.php +++ b/tests/Feature/Landing/HomepageRenderTest.php @@ -503,9 +503,18 @@ function homeSectionIds(string $html): array it('thanks only the verified sponsors, each as a sponsored link', function (): void { $html = ssrHome('/'); + $data = homeData('sponsors.json'); - foreach (homeData('sponsors.json')['sponsors'] as $sponsor) { - expect($html)->toMatch('#]*href="' . preg_quote(htmlspecialchars($sponsor['url'], ENT_QUOTES), '#') . '"[^>]*rel="sponsored noopener"#'); + preg_match('#]*id="sponsors".*?#s', $html, $section); + + foreach ($data['sponsors'] as $sponsor) { + $link = '#]*href="' . preg_quote(htmlspecialchars($sponsor['url'], ENT_QUOTES), '#') . '"[^>]*rel="sponsored noopener"#'; + + if (in_array($sponsor['placement'], ['lead', 'homepage'], true)) { + expect($section[0] ?? '')->toMatch($link); + } else { + expect($html)->not->toContain(htmlspecialchars($sponsor['url'], ENT_QUOTES)); + } } foreach (['getapps', 'Visnalize', 'Unikorn', 'Xermius'] as $former) { @@ -513,6 +522,23 @@ function homeSectionIds(string $html): array } })->group('ssr'); + it('counts the open homepage places from the data and invites a sponsor to the sponsors page', function (string $path, string $prefix): void { + $data = homeData('sponsors.json'); + $placed = count(array_filter($data['sponsors'], fn(array $sponsor): bool => in_array($sponsor['placement'], ['lead', 'homepage'], true))); + $open = max(0, $data['homepageSlots'] - $placed); + $locale = $prefix === '' ? 'en' : ltrim($prefix, '/'); + $invite = json_decode((string) file_get_contents(resource_path("data/content/{$locale}/home.json")), true)['sponsors']['invite']; + + preg_match('#]*id="sponsors".*?#s', ssrHome($path), $section); + + expect($open)->toBeGreaterThan(0, 'The invite cell is hidden when every place is taken; this test needs an open one'); + expect(substr_count($section[0] ?? '', 'toBe($placed + 1); + expect(substr_count($section[0] ?? '', "href=\"{$prefix}/sponsors\""))->toBe(2, 'The heading link and the invite cell'); + expect($section[0] ?? '')->toContain(htmlspecialchars($invite['title'], ENT_QUOTES)) + ->toContain(str_replace(['{count}', '{total}'], [(string) $open, (string) $data['homepageSlots']], $invite['open'][$open === 1 ? 'one' : 'other'])) + ->not->toContain('github.com/sponsors'); + })->with([['/', ''], ['/vi', '/vi']])->group('ssr'); + it('renders a title built from platforms.json, Product Hunt only as a text link, and wraps the sponsor logos', function (string $path, string $title, string $productHunt): void { $html = ssrHome($path); diff --git a/tests/Feature/Landing/LandingTest.php b/tests/Feature/Landing/LandingTest.php index 66738cb8..1267bb9d 100644 --- a/tests/Feature/Landing/LandingTest.php +++ b/tests/Feature/Landing/LandingTest.php @@ -29,6 +29,7 @@ function landingFamilies(): array 'landing.faq' => ['Faq', ['content', 'facts', 'platforms']], 'landing.about' => ['About', ['content', 'publisher.name', 'repositoryCreated', 'links']], 'landing.security' => ['Security', ['content.sections.0.items', 'facts.macSafeModeLevels', 'links.email']], + 'landing.sponsors' => ['Sponsors', ['content.header.title', 'content.tiers.benefit.name', 'organizationProfiles']], 'landing.privacy' => ['Privacy', ['document.title', 'document.html', 'document.toc']], 'landing.terms' => ['Terms', ['document.title', 'document.html', 'document.toc']], 'landing.refundPolicy' => ['RefundPolicy', ['document.title', 'document.html', 'document.toc']], diff --git a/tests/Feature/Landing/SponsorsPageTest.php b/tests/Feature/Landing/SponsorsPageTest.php new file mode 100644 index 00000000..a7223ea1 --- /dev/null +++ b/tests/Feature/Landing/SponsorsPageTest.php @@ -0,0 +1,132 @@ + + */ +function sponsorsPageLocales(): array +{ + return array_keys(json_decode((string) file_get_contents(__DIR__ . '/../../../resources/data/locales.json'), true)['supported']); +} + +/** + * @return array + */ +function sponsorsPageData(): array +{ + return json_decode(File::get(resource_path('data/sponsors.json')), true, 512, JSON_THROW_ON_ERROR); +} + +/** + * @return array + */ +function sponsorsPageContent(string $locale): array +{ + return json_decode(File::get(resource_path("data/content/{$locale}/sponsors.json")), true, 512, JSON_THROW_ON_ERROR); +} + +function sponsorsPageSection(string $html, string $id): string +{ + preg_match('#]*id="' . $id . '".*?#s', $html, $section); + + return $section[0] ?? ''; +} + +it('renders in every locale', function (string $locale): void { + $path = $locale === Locales::default() ? '/sponsors' : "/{$locale}/sponsors"; + + get($path) + ->assertOk() + ->assertInertia(fn(AssertableInertia $page) => $page + ->component('Sponsors') + ->where('locale', $locale) + ->where('content.header.title', sponsorsPageContent($locale)['header']['title']) + ->where('seo.robots', 'index, follow')); +})->with(sponsorsPageLocales()); + +it('is indexed in every language, with every translation as an alternate', function (): void { + $entry = app(PageRegistry::class)->find('landing.sponsors', []); + + expect($entry->renderLocales)->toBe(Locales::codes()); + expect($entry->hreflangCluster())->toBe(Locales::codes()); +}); + +it('takes every count and price from the data, never from the copy', function (string $locale): void { + $text = implode("\n", array_filter(Arr::dot(sponsorsPageContent($locale)), 'is_string')); + + preg_match_all('/\{([A-Za-z]+)\}/', $text, $slots); + + expect(array_values(array_diff(array_unique($slots[1]), ['count', 'total', 'price'])))->toBe([]); + expect($text)->not->toMatch('/\$\s?\d|\d\s?(US)?\$|\d\s?€|<[a-z]+>/u'); + expect($text)->not->toMatch('/(?with(sponsorsPageLocales()); + +it('server-renders the sponsors by placement, the open places, every tier and a WebPage', function (string $locale): void { + $data = sponsorsPageData(); + $content = sponsorsPageContent($locale); + $prefix = $locale === Locales::default() ? '' : "/{$locale}"; + $html = ssrHtml("{$prefix}/sponsors"); + + preg_match_all('#]*\bid="([a-z-]+)"#', (string) strstr($html, 'toBe(['companies', 'individuals', 'tiers']); + + $companies = sponsorsPageSection($html, 'companies'); + $individuals = sponsorsPageSection($html, 'individuals'); + $tiers = sponsorsPageSection($html, 'tiers'); + + foreach ($data['sponsors'] as $sponsor) { + $url = htmlspecialchars($sponsor['url'], ENT_QUOTES); + $link = '#]*href="' . preg_quote($url, '#') . '"[^>]*rel="sponsored noopener"[^>]*>(.*?)#s'; + + if ($sponsor['placement'] === 'name') { + expect($individuals)->toMatch($link); + expect($companies)->not->toContain($url); + } else { + expect(preg_match($link, $companies, $match))->toBe(1, "{$sponsor['name']} has no sponsored link in #companies"); + expect($match[1])->toContain('toContain($sponsor['name']); + } + } + + $placed = count(array_filter($data['sponsors'], fn(array $sponsor): bool => in_array($sponsor['placement'], ['lead', 'homepage'], true))); + $open = max(0, $data['homepageSlots'] - $placed); + $line = $open > 0 ? $content['companies']['open'][$open === 1 ? 'one' : 'other'] : $content['companies']['full']; + + expect($companies)->toContain(htmlspecialchars(str_replace(['{count}', '{total}'], [(string) $open, (string) $data['homepageSlots']], $line), ENT_QUOTES)); + expect(substr_count($tiers, 'toContain('$' . number_format($tier['monthly'])) + ->toContain('$' . number_format($tier['monthly'] * $data['oneTime']['multiplier'])); + } + } + + expect($tiers)->toContain('href="https://github.com/sponsors/datlechin"'); + + $document = HTMLDocument::createFromString($html, LIBXML_NOERROR); + $nodes = []; + + foreach ($document->querySelectorAll('script[type="application/ld+json"]') as $script) { + array_push($nodes, ...(json_decode((string) $script->textContent, true)['@graph'] ?? [])); + } + + $page = collect($nodes)->firstWhere('@type', 'WebPage'); + + expect($page)->not->toBeNull() + ->and($page['inLanguage'])->toBe(Locales::definition($locale)['hreflang']) + ->and($page['url'])->toEndWith("{$prefix}/sponsors"); +})->with(['en', 'vi'])->group('ssr'); diff --git a/tests/Feature/Localization/LocaleRoutingTest.php b/tests/Feature/Localization/LocaleRoutingTest.php index 3900f394..b4c5f89f 100644 --- a/tests/Feature/Localization/LocaleRoutingTest.php +++ b/tests/Feature/Localization/LocaleRoutingTest.php @@ -182,7 +182,7 @@ function retiredStatus(string $path): ?int it('never lets a slug list swallow a root path or a locale prefix', function (): void { $reserved = [ - 'ios', 'download', 'pricing', 'faq', 'about', 'security', 'privacy', 'terms', 'refund-policy', 'brand', 'blog', 'features', + 'ios', 'download', 'pricing', 'faq', 'about', 'security', 'sponsors', 'privacy', 'terms', 'refund-policy', 'brand', 'blog', 'features', 'databases', 'compare', 'integrations', 'robots.txt', 'sitemap.xml', 'up', 'account', 'checkout', 'og', 'images', 'build', ...array_filter(array_column(Locales::all(), 'prefix')), ]; diff --git a/tests/js/sponsors.test.ts b/tests/js/sponsors.test.ts new file mode 100644 index 00000000..65d4bbf8 --- /dev/null +++ b/tests/js/sponsors.test.ts @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import type { Sponsor, SponsorsData } from '../../resources/js/lib/data/sponsors.ts'; +import { byName, onHomepage, openSlots } from '../../resources/js/lib/sponsors.ts'; + +const LOGO = { light: '/images/sponsors/x.svg', dark: null, width: 100, height: 20 }; + +function sponsor(id: string, placement: Sponsor['placement']): Sponsor { + const base = { id, name: id, githubLogin: id, url: `https://${id}.example` }; + + return placement === 'name' ? { ...base, placement, logo: null } : { ...base, placement, logo: LOGO }; +} + +function fixture(sponsors: Sponsor[], homepageSlots = 3): SponsorsData { + return { + verifiedAt: '2026-10-29', + verification: { method: 'test', program: 'https://example.com' }, + homepageSlots, + leadSlots: 1, + oneTime: { multiplier: 10, months: 12 }, + tiers: [ + { placement: 'name', monthly: 10 }, + { placement: 'homepage', monthly: 100 }, + { placement: 'lead', monthly: 250 }, + ], + sponsors, + }; +} + +const ids = (list: Sponsor[]): string[] => list.map((item) => item.id); + +test('puts the lead first and keeps data order after it', () => { + const data = fixture([sponsor('a', 'homepage'), sponsor('b', 'name'), sponsor('c', 'lead'), sponsor('d', 'homepage')]); + + assert.deepEqual(ids(onHomepage(data)), ['c', 'a', 'd']); + assert.deepEqual(ids(byName(data)), ['b']); +}); + +test('counts the lead against the homepage slots and never goes below zero', () => { + assert.equal(openSlots(fixture([sponsor('a', 'lead'), sponsor('b', 'name')])), 2); + assert.equal(openSlots(fixture([sponsor('a', 'homepage'), sponsor('b', 'homepage'), sponsor('c', 'homepage')])), 0); + assert.equal(openSlots(fixture([sponsor('a', 'homepage'), sponsor('b', 'homepage'), sponsor('c', 'homepage'), sponsor('d', 'homepage')])), 0); + assert.equal(openSlots(fixture([])), 3); +});