Skip to content

Commit 1c29364

Browse files
committed
more partners
1 parent cb25c94 commit 1c29364

9 files changed

Lines changed: 491 additions & 13 deletions

File tree

bitext/src/lib/brand.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const ALIGNER_DISPLAY_NAME = 'Aligner';
1313
/** Multiline tooltip for partner link cards; shown on “?” hint. */
1414
export const PARTNER_LINK_WHY_TOOLTIP = `Why is this here?
1515
16-
${ALIGNER_DISPLAY_NAME} stays free and without aggressive ads. Hosting and ongoing upkeep still have a cost, so we add a few optional partner links - use them if you were already considering the service. It will help us keep the site running. The referral bonuses come from the provider. Here I recommend the services that I happily use myself.
16+
${ALIGNER_DISPLAY_NAME} stays free. Upkeep still has a cost, so we add a few partner links. Use them if you consider the service. It helps us to maintain the app. I recommend only the services I happily use myself.
1717
1818
Thanks,
1919
Dani`;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script lang="ts">
2+
import PartnerBannerShell from './PartnerBannerShell.svelte';
3+
4+
const href = 'https://cursor.com/referral?code=T8B3DYFJSOF5';
5+
</script>
6+
7+
<PartnerBannerShell
8+
title="Cursor — AI code editor"
9+
{href}
10+
ctaLabel="Open Cursor"
11+
partner="cursor"
12+
product="pro_subscription"
13+
toneClass="border-l-[#141414] bg-[#141414]/[0.06] dark:border-l-neutral-200 dark:bg-neutral-200/10"
14+
>
15+
<p class="m-0">
16+
Cursor is my main AI coding tool. If you were going to try it anyway, this referral
17+
gives new accounts <strong class="font-medium text-gray-800 dark:text-gray-200"
18+
>50% off the first month</strong
19+
>
20+
of Pro, Pro+, or Ultra (per Cursor’s current offer).
21+
</p>
22+
</PartnerBannerShell>

bitext/src/lib/components/partners/PartnerBannerShell.svelte

Lines changed: 118 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<script lang="ts">
2-
import type { Snippet } from 'svelte';
2+
import { browser } from '$app/environment';
3+
import { tick, type Snippet } from 'svelte';
4+
import type { Action } from 'svelte/action';
35
import { PARTNER_LINK_WHY_TOOLTIP } from '$lib/brand.js';
46
57
let {
@@ -23,6 +25,93 @@
2325
children: Snippet;
2426
} = $props();
2527
28+
const tipId = $derived(`partner-why-tip-${partner}`);
29+
30+
let whyOpen = $state(false);
31+
let whyWrapEl = $state<HTMLElement | null>(null);
32+
let tipPortalEl = $state<HTMLElement | null>(null);
33+
let layoutNarrow = $state(false);
34+
35+
const tipLayoutSmMq = '(min-width: 640px)';
36+
37+
/** Escapes `overflow: hidden` ancestors (e.g. page shell) so the tooltip is not clipped. */
38+
const appendToBody: Action<HTMLElement> = (node) => {
39+
document.body.appendChild(node);
40+
return {
41+
destroy() {
42+
node.remove();
43+
}
44+
};
45+
};
46+
47+
$effect(() => {
48+
if (!browser) return;
49+
const mq = window.matchMedia(tipLayoutSmMq);
50+
layoutNarrow = !mq.matches;
51+
const onChange = () => {
52+
layoutNarrow = !mq.matches;
53+
};
54+
mq.addEventListener('change', onChange);
55+
return () => mq.removeEventListener('change', onChange);
56+
});
57+
58+
const showTipPortal = $derived(whyOpen && layoutNarrow);
59+
60+
function syncPartnerTipPortal() {
61+
if (!browser || !whyOpen || !layoutNarrow || !whyWrapEl || !tipPortalEl) return;
62+
const r = whyWrapEl.getBoundingClientRect();
63+
const gap = 6;
64+
tipPortalEl.style.setProperty('top', `${r.bottom + gap}px`);
65+
tipPortalEl.style.setProperty('left', 'max(1rem, env(safe-area-inset-left, 0px))');
66+
tipPortalEl.style.setProperty('right', 'max(1rem, env(safe-area-inset-right, 0px))');
67+
tipPortalEl.style.setProperty('width', 'auto');
68+
tipPortalEl.style.setProperty('margin-top', '0');
69+
}
70+
71+
function toggleWhy(e: MouseEvent) {
72+
e.stopPropagation();
73+
whyOpen = !whyOpen;
74+
}
75+
76+
$effect(() => {
77+
if (!browser || !whyOpen) return;
78+
function onDocClick(ev: MouseEvent) {
79+
const target = ev.target;
80+
if (!(target instanceof Node)) return;
81+
if (whyWrapEl?.contains(target)) return;
82+
const tipNode = document.getElementById(tipId);
83+
if (tipNode?.contains(target)) return;
84+
whyOpen = false;
85+
}
86+
queueMicrotask(() => {
87+
document.addEventListener('click', onDocClick);
88+
});
89+
return () => document.removeEventListener('click', onDocClick);
90+
});
91+
92+
$effect(() => {
93+
if (!browser || !whyOpen || !layoutNarrow) return;
94+
95+
tipPortalEl;
96+
97+
let cancelled = false;
98+
const run = () => {
99+
if (!cancelled) syncPartnerTipPortal();
100+
};
101+
102+
void tick().then(run);
103+
104+
window.addEventListener('resize', run);
105+
window.addEventListener('scroll', run, true);
106+
107+
return () => {
108+
cancelled = true;
109+
window.removeEventListener('resize', run);
110+
window.removeEventListener('scroll', run, true);
111+
if (tipPortalEl) tipPortalEl.style.cssText = '';
112+
};
113+
});
114+
26115
const linkClass =
27116
'inline-flex max-w-full items-center gap-1 text-sm font-medium text-primary-700 underline decoration-primary-700/40 underline-offset-2 hover:text-primary-800 hover:decoration-primary-800 dark:text-primary-400 dark:decoration-primary-400/50 dark:hover:text-primary-300';
28117
</script>
@@ -38,20 +127,28 @@
38127
class="font-heading m-0 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 self-start text-base font-semibold leading-snug text-gray-900 sm:col-start-1 sm:row-start-1 sm:text-lg dark:text-white"
39128
>
40129
<span class="min-w-0">{title}</span>
41-
<span class="relative inline-flex shrink-0 items-center">
130+
<span class="group relative inline-flex shrink-0 items-center" bind:this={whyWrapEl}>
42131
<button
43132
type="button"
44-
class="peer inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-gray-300/90 bg-gray-50/80 text-[10px] font-normal leading-none text-gray-400 transition-colors hover:border-gray-400 hover:bg-gray-100 hover:text-gray-600 focus-visible:outline focus-visible:ring-2 focus-visible:ring-gray-400 dark:border-gray-600 dark:bg-gray-800/60 dark:text-gray-500 dark:hover:border-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300 dark:focus-visible:ring-gray-500"
133+
class="peer inline-flex h-4 w-4 shrink-0 touch-manipulation cursor-pointer items-center justify-center rounded-full border border-gray-300/90 bg-gray-50/80 text-[10px] font-normal leading-none text-gray-400 transition-colors hover:border-gray-400 hover:bg-gray-100 hover:text-gray-600 focus-visible:outline focus-visible:ring-2 focus-visible:ring-gray-400 dark:border-gray-600 dark:bg-gray-800/60 dark:text-gray-500 dark:hover:border-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300 dark:focus-visible:ring-gray-500"
45134
aria-label="Why is this here?"
135+
aria-expanded={whyOpen}
136+
aria-controls={tipId}
137+
onclick={toggleWhy}
46138
>
47139
?
48140
</button>
49-
<span
50-
role="tooltip"
51-
class="pointer-events-none absolute top-full left-1/2 z-[60] mt-1.5 hidden w-max max-w-[min(24rem,calc(100vw-2rem))] -translate-x-1/2 whitespace-pre-line rounded-md bg-gray-900 px-3 py-2.5 text-left text-xs font-normal leading-snug text-white shadow-md peer-hover:block peer-focus-visible:block dark:bg-gray-700"
52-
>
53-
{PARTNER_LINK_WHY_TOOLTIP}
54-
</span>
141+
{#if !showTipPortal}
142+
<span
143+
id={tipId}
144+
role="tooltip"
145+
class="pointer-events-auto absolute top-full left-1/2 z-[60] mt-1.5 w-max max-w-[min(24rem,calc(100vw-2rem))] -translate-x-1/2 whitespace-pre-line rounded-md bg-gray-900 px-3 py-2.5 text-left text-xs font-normal leading-snug text-white shadow-md dark:bg-gray-700 {whyOpen
146+
? 'block'
147+
: 'hidden sm:group-hover:block sm:peer-focus-visible:block'}"
148+
>
149+
{PARTNER_LINK_WHY_TOOLTIP}
150+
</span>
151+
{/if}
55152
</span>
56153
</h3>
57154
<div
@@ -72,3 +169,15 @@
72169
</a>
73170
</div>
74171
</article>
172+
173+
{#if showTipPortal}
174+
<div
175+
bind:this={tipPortalEl}
176+
use:appendToBody
177+
id={tipId}
178+
role="tooltip"
179+
class="pointer-events-auto fixed z-[100] box-border max-w-[min(24rem,calc(100vw-2rem))] whitespace-pre-line rounded-md bg-gray-900 px-3 py-2.5 text-left text-xs font-normal leading-snug text-white shadow-md dark:bg-gray-700"
180+
>
181+
{PARTNER_LINK_WHY_TOOLTIP}
182+
</div>
183+
{/if}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script lang="ts">
2+
import PartnerBannerShell from './PartnerBannerShell.svelte';
3+
4+
const href = 'https://wise.com/invite/dlpc/danip204';
5+
</script>
6+
7+
<PartnerBannerShell
8+
title="Wise — international transfers"
9+
{href}
10+
ctaLabel="Open Wise"
11+
partner="wise"
12+
product="money_transfer"
13+
toneClass="border-l-[#9fe870] bg-[#9fe870]/10 dark:bg-[#9fe870]/15"
14+
>
15+
<p class="m-0">
16+
Sometimes I have trouble with money transfers in my country. Wise worked for me without too much hassle. With this invite, new sign-ups get a
17+
<strong class="font-medium text-gray-800 dark:text-gray-200">fee-free first transfer</strong>
18+
up to roughly <strong class="font-medium text-gray-800 dark:text-gray-200">US$600</strong>
19+
equivalent.
20+
</p>
21+
</PartnerBannerShell>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Home page shows two partner banners in fixed regions (intro column + settings sidebar).
3+
* Each hour (UTC, by Unix time) we pick two *distinct* partners from `HOME_PARTNER_IDS`;
4+
* all ids participate in rotation as the set grows.
5+
*
6+
* Order is computed once per request on the server so SSR and hydration match.
7+
*/
8+
export const HOME_PARTNER_IDS = ['preply', 'railway', 'cursor', 'wise'] as const;
9+
export type HomePartnerId = (typeof HOME_PARTNER_IDS)[number];
10+
11+
function mulberry32(seed: number) {
12+
return function () {
13+
let t = (seed += 0x6d2b79f5);
14+
t = Math.imul(t ^ (t >>> 15), t | 1);
15+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
16+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
17+
};
18+
}
19+
20+
/** Uniform random shuffle (Fisher–Yates). Mutates `items`. */
21+
function shuffleInPlace<T>(items: T[], rng: () => number): void {
22+
for (let i = items.length - 1; i > 0; i--) {
23+
const j = Math.floor(rng() * (i + 1));
24+
[items[i], items[j]] = [items[j], items[i]];
25+
}
26+
}
27+
28+
/**
29+
* `[introColumn, settingsSidebar]` — two different partners; every ordered pair among
30+
* `HOME_PARTNER_IDS` is equally likely for a given draw.
31+
*/
32+
export function getHomePartnerOrder(nowMs: number): [HomePartnerId, HomePartnerId] {
33+
const bucket = homePartnerHourBucketUtc(nowMs);
34+
const seed = Math.imul(bucket ^ 0xdeadbeef, 0x9e3779b1) | 0;
35+
const rng = mulberry32(seed);
36+
const pool = [...HOME_PARTNER_IDS] as HomePartnerId[];
37+
shuffleInPlace(pool, rng);
38+
if (pool.length < 2) {
39+
throw new Error('HOME_PARTNER_IDS must contain at least two partners for two slots');
40+
}
41+
return [pool[0], pool[1]];
42+
}
43+
44+
/** Unix ms → bucket index; advances every 3600 s of Unix time (UTC-hour boundaries). */
45+
export function homePartnerHourBucketUtc(nowMs: number): number {
46+
return Math.floor(nowMs / 3_600_000);
47+
}

bitext/src/routes/+page.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { getHomePartnerOrder } from '$lib/partners/home-rotation.js';
12
import { decodeState } from '$lib/serialization/decode.js';
23
import type { PageServerLoad } from './$types';
34

45
export const load: PageServerLoad = ({ url }) => {
56
const data = url.searchParams.get('data');
67
return {
78
dataParam: data,
8-
initialState: data ? decodeState(data) : null
9+
initialState: data ? decodeState(data) : null,
10+
homePartnerOrder: getHomePartnerOrder(Date.now())
911
};
1012
};

bitext/src/routes/+page.svelte

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
import SettingsPanel from '$lib/components/settings/SettingsPanel.svelte';
99
import ExportCard from '$lib/components/settings/ExportCard.svelte';
1010
import ShareQuickRow from '$lib/components/share/ShareQuickRow.svelte';
11+
import PartnerBannerCursor from '$lib/components/partners/PartnerBannerCursor.svelte';
1112
import PartnerBannerPreply from '$lib/components/partners/PartnerBannerPreply.svelte';
1213
import PartnerBannerRailway from '$lib/components/partners/PartnerBannerRailway.svelte';
14+
import PartnerBannerWise from '$lib/components/partners/PartnerBannerWise.svelte';
15+
import type { HomePartnerId } from '$lib/partners/home-rotation.js';
1316
import SeoIntro from '$lib/components/seo/SeoIntro.svelte';
1417
import SeoSections from '$lib/components/seo/SeoSections.svelte';
1518
import JsonLd from '$lib/components/seo/JsonLd.svelte';
@@ -33,9 +36,17 @@
3336
import { TALLY_FORM_ID } from '$lib/brand.js';
3437
import { DEFAULT_DESCRIPTION, DEFAULT_TITLE, SITE_NAME } from '$lib/seo/metadata.js';
3538
import type { PageProps } from './$types';
39+
import type { Component } from 'svelte';
3640
3741
let { data }: PageProps = $props();
3842
43+
const homeBannerById: Record<HomePartnerId, Component> = {
44+
preply: PartnerBannerPreply,
45+
railway: PartnerBannerRailway,
46+
cursor: PartnerBannerCursor,
47+
wise: PartnerBannerWise
48+
};
49+
3950
let hydrated = $state(false);
4051
let previewExpand = $state(false);
4152
@@ -260,7 +271,10 @@
260271
</p>
261272
</div>
262273
<div class="w-full min-w-0 lg:w-auto lg:flex-1">
263-
<PartnerBannerPreply />
274+
{#key data.homePartnerOrder[0]}
275+
{@const HomePartnerIntroBanner = homeBannerById[data.homePartnerOrder[0]]}
276+
<HomePartnerIntroBanner />
277+
{/key}
264278
</div>
265279
</div>
266280
</header>
@@ -500,7 +514,10 @@
500514
<ShareQuickRow />
501515
</div>
502516
<div class="mt-6 min-w-0">
503-
<PartnerBannerRailway />
517+
{#key data.homePartnerOrder[1]}
518+
{@const HomePartnerSidebarBanner = homeBannerById[data.homePartnerOrder[1]]}
519+
<HomePartnerSidebarBanner />
520+
{/key}
504521
</div>
505522
<p class="mt-2 text-center">
506523
<!-- Looks like a text link; <button> avoids SvelteKit href + eslint; Tally uses data-tally-*. -->

bitext/src/routes/about/+page.svelte

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
import { page } from '$app/state';
33
import { resolve } from '$app/paths';
44
import { SITE_CONTACT_EMAIL, TALLY_FORM_ID } from '$lib/brand.js';
5+
import PartnerBannerCursor from '$lib/components/partners/PartnerBannerCursor.svelte';
56
import PartnerBannerPreply from '$lib/components/partners/PartnerBannerPreply.svelte';
67
import PartnerBannerRailway from '$lib/components/partners/PartnerBannerRailway.svelte';
8+
import PartnerBannerWise from '$lib/components/partners/PartnerBannerWise.svelte';
79
import { settingsStore } from '$lib/state/settings.svelte.js';
810
911
const TITLE = 'About';
@@ -438,6 +440,8 @@
438440
<div class="mt-5 flex min-w-0 flex-col gap-4">
439441
<PartnerBannerPreply />
440442
<PartnerBannerRailway />
443+
<PartnerBannerCursor />
444+
<PartnerBannerWise />
441445
</div>
442446

443447
<h2 id="doc-contact" class={headingClass}>Contact</h2>

0 commit comments

Comments
 (0)