Skip to content

Commit b8b4cf2

Browse files
committed
Add partner placement rotation
1 parent 4d84d60 commit b8b4cf2

17 files changed

Lines changed: 1114 additions & 197 deletions

docs/partner-placement.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Partner Placement
2+
3+
Partner placement is split into three separate decisions:
4+
5+
- **Eligibility:** whether a partner can appear for a surface, category, library, feature, or user context.
6+
- **Tier:** the partner's commercial/display tier, which controls visual treatment and relative access to surfaces.
7+
- **Order:** the sequence used inside a surface once eligible partners are known.
8+
9+
The order policy should be explicit per surface. Avoid treating the partner array order or legacy score as the policy itself.
10+
11+
## Order Strategies
12+
13+
### `static-curated`
14+
15+
Use for surfaces where the order is editorially or commercially curated and should remain stable. These surfaces can preserve the existing order, use partner seniority, or use private commercial priority when seniority is unavailable.
16+
17+
Do not expose private commercial criteria in client-facing field names, comments, or API responses. If a private priority is required, keep the public/client value generic, such as `placementPriority` or an already-resolved ordered list.
18+
19+
### `tier-rotated`
20+
21+
Use for visible partner surfaces where partners in the same tier should cycle over time. Tier order remains fixed, but partners inside the same tier are selected with weighted-random ordering for each page view and surface.
22+
23+
The root loader creates the initial page-view seed, so SSR and hydration receive the same value through loader data. A React provider owns the seed after hydration and refreshes it on client-side page views. Each surface combines its stable surface id with the page-view seed; avoid runtime-generated component ids in the seed because they can diverge across server and client rendering. Partners default to equal probability within their tier; a future `placementWeight` can bias same-tier odds without changing the ordering API.
24+
25+
### `contextual-recommendation`
26+
27+
Use when a surface behaves like a product recommendation or builder suggestion. Product fit should be established before commercial weighting affects order. This strategy currently preserves the legacy tier/priority behavior while giving recommendation surfaces a separate policy label.
28+
29+
### `machine-readable`
30+
31+
Use for AI- or crawler-facing outputs such as `llms.txt` and JSON feeds. These should be deterministic and relevance-first. Do not rotate them merely for logo fairness unless the downstream behavior has been designed and disclosed as such.
32+
33+
## Reserved Rules
34+
35+
Reserved rules should be narrow. For example, Cloudflare is reserved as the first deployment/hosting partner in any list that contains Cloudflare and other deployment partners. That should not imply Cloudflare is globally first across every partner surface; non-deployment partners can still appear before Cloudflare when the surface is mixed-category.
36+
37+
Deployment action buttons are a deployment-only surface. Cloudflare remains first when present. Tier order is still preserved, and providers within the same tier can rotate by page view.
38+
39+
## Current Surfaces
40+
41+
- Partner directory: `tier-rotated` for active partners, `static-curated` for previous partners.
42+
- Partner grids and embeds: `tier-rotated`.
43+
- Docs and blog rails: `tier-rotated`.
44+
- Mobile docs strip: `tier-rotated`.
45+
- Builder feature picker and starter partner suggestions: `contextual-recommendation`.
46+
- Deploy action buttons: `tier-rotated` with deployment provider tiers preserved.
47+
- `llms.txt` and `/api/data/partners`: `machine-readable`.
48+
49+
## Analytics
50+
51+
Partner impression and click events should include:
52+
53+
- `partner_id`
54+
- `placement`
55+
- `slot_index`
56+
- `partner_tier`
57+
- `order_strategy`
58+
- `rotation_seed` when the strategy rotates
59+
60+
This lets reporting distinguish partner performance from placement policy and makes under/over-exposure easier to debug.
61+
62+
## Contract Language
63+
64+
Suggested external framing:
65+
66+
> Partner tiers determine eligibility, visual treatment, reporting, and relative access to surfaces. Placement within the same tier may rotate or be curated depending on the surface. Some placements may include explicitly reserved rules for product, infrastructure, legal, or strategic reasons.
67+
68+
For AI-assisted selection:
69+
70+
> AI-assisted partner selection prioritizes user need and capability fit first. Partner tier may influence selection only among qualified options.

src/components/ApplicationStarter.tsx

Lines changed: 137 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ import {
1212
import { twMerge } from 'tailwind-merge'
1313
import anthropicDarkLogo from '~/images/anthropic-dark.svg'
1414
import anthropicLightLogo from '~/images/anthropic-light.svg'
15+
import cloudflareBlackLogo from '~/images/cloudflare-black.svg'
16+
import cloudflareWhiteLogo from '~/images/cloudflare-white.svg'
17+
import netlifyDarkLogo from '~/images/netlify-dark.svg'
18+
import netlifyLightLogo from '~/images/netlify-light.svg'
1519
import openaiDarkLogo from '~/images/openai-dark.svg'
1620
import openaiLightLogo from '~/images/openai-light.svg'
21+
import railwayBlackLogo from '~/images/railway-black.svg'
1722
import type {
1823
ApplicationStarterContext,
1924
ApplicationStarterResult,
@@ -37,6 +42,11 @@ import {
3742
type StarterTone,
3843
} from '~/components/application-builder/shared'
3944
import { useApplicationBuilder } from '~/components/application-builder/useApplicationBuilder'
45+
import {
46+
deploymentProviderIds,
47+
type DeploymentProviderId,
48+
useDeploymentProviderPlacement,
49+
} from '~/utils/useDeploymentProviderPlacement'
4050
import { Button, GitHub } from '~/ui'
4151

4252
export interface ApplicationStarterProps {
@@ -165,6 +175,10 @@ export function ApplicationStarter({
165175
onResolvedResult,
166176
suggestionContext,
167177
})
178+
const orderedDeploymentProviders = useDeploymentProviderPlacement({
179+
availableProviders: deploymentProviderIds,
180+
surface: `application_starter_deploy_actions:${context}`,
181+
})
168182

169183
const palette = toneClasses[tone]
170184
const compact = mode === 'compact'
@@ -201,6 +215,110 @@ export function ApplicationStarter({
201215
hasInput &&
202216
!hasMigrationRepositoryUrlError &&
203217
!isGenerating
218+
const renderDeploymentProviderButton = (provider: DeploymentProviderId) => {
219+
switch (provider) {
220+
case 'cloudflare':
221+
return (
222+
<Button
223+
key={provider}
224+
size="md"
225+
type="button"
226+
onClick={() => {
227+
void openDeployDialog('cloudflare')
228+
}}
229+
disabled={!canUseFinalActions}
230+
aria-label="Cloudflare"
231+
className="h-[60px] min-w-[196px] border-[#F48120]/50 bg-white px-5 text-gray-950 shadow-sm hover:bg-[#fff7ef] disabled:opacity-80 disabled:saturate-75 dark:border-[#F48120]/70 dark:bg-gray-950 dark:text-white dark:hover:bg-[#2a1808]"
232+
>
233+
<span className="relative h-7 w-[164px] shrink-0">
234+
<img
235+
src={cloudflareBlackLogo}
236+
alt=""
237+
aria-hidden="true"
238+
className="h-full w-full object-contain dark:hidden"
239+
/>
240+
<img
241+
src={cloudflareWhiteLogo}
242+
alt=""
243+
aria-hidden="true"
244+
className="hidden h-full w-full object-contain dark:block"
245+
/>
246+
</span>
247+
</Button>
248+
)
249+
case 'netlify':
250+
return (
251+
<Button
252+
key={provider}
253+
size="md"
254+
type="button"
255+
onClick={() => void openNetlifyStart()}
256+
disabled={!canUseFinalActions}
257+
aria-label="Netlify"
258+
className="h-11 min-w-[136px] border-gray-200 bg-white px-4 text-gray-700 shadow-sm hover:bg-gray-50 disabled:opacity-65 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800"
259+
>
260+
{isGeneratingNetlify ? (
261+
<Loader2 className="h-5 w-5 animate-spin" />
262+
) : (
263+
<span className="relative h-8 w-[78px] shrink-0">
264+
<img
265+
src={netlifyLightLogo}
266+
alt=""
267+
aria-hidden="true"
268+
className="h-full w-full object-contain dark:hidden"
269+
/>
270+
<img
271+
src={netlifyDarkLogo}
272+
alt=""
273+
aria-hidden="true"
274+
className="hidden h-full w-full object-contain dark:block"
275+
/>
276+
</span>
277+
)}
278+
</Button>
279+
)
280+
case 'railway':
281+
return (
282+
<Button
283+
key={provider}
284+
size="md"
285+
type="button"
286+
onClick={() => {
287+
void openDeployDialog('railway')
288+
}}
289+
disabled={!canUseFinalActions}
290+
aria-label="Railway"
291+
className="h-[60px] min-w-[174px] border-gray-200 bg-white px-5 text-gray-950 shadow-sm hover:bg-gray-50 disabled:opacity-80 disabled:saturate-75 dark:border-gray-700 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100"
292+
>
293+
<span className="relative h-7 w-[132px] shrink-0">
294+
<img
295+
src={railwayBlackLogo}
296+
alt=""
297+
aria-hidden="true"
298+
className="h-full w-full object-contain"
299+
/>
300+
</span>
301+
</Button>
302+
)
303+
}
304+
}
305+
const renderGeneratePromptButton = () => (
306+
<Button
307+
color={buttonColor}
308+
variant={hasGeneratedPrompt ? 'secondary' : 'primary'}
309+
size="sm"
310+
type="button"
311+
onClick={() => void generatePrompt()}
312+
disabled={!canUseFinalActions}
313+
>
314+
{isGeneratingPrompt ? (
315+
<Loader2 className="h-4 w-4 animate-spin" />
316+
) : (
317+
<Wand2 className="h-4 w-4" />
318+
)}
319+
{isGeneratingPrompt ? loadingPhrase : primaryActionLabel}
320+
</Button>
321+
)
204322
const showPostAnalysisSection =
205323
alwaysShowPostAnalysisSection || hasFreshAnalysis || hasGeneratedPrompt
206324
const showActionSection =
@@ -769,81 +887,33 @@ export function ApplicationStarter({
769887
)}
770888
>
771889
<div className="flex flex-wrap items-center gap-3">
772-
<Button
773-
color={buttonColor}
774-
variant={
775-
hasGeneratedPrompt ? 'secondary' : 'primary'
776-
}
777-
size="sm"
778-
type="button"
779-
onClick={() => void generatePrompt()}
780-
disabled={!canUseFinalActions}
781-
>
782-
{isGeneratingPrompt ? (
783-
<Loader2 className="h-4 w-4 animate-spin" />
784-
) : (
785-
<Wand2 className="h-4 w-4" />
786-
)}
787-
{isGeneratingPrompt
788-
? loadingPhrase
789-
: primaryActionLabel}
790-
</Button>
791-
792890
{showCliExportActions ? (
793-
<>
794-
<Button
795-
size="sm"
796-
type="button"
797-
onClick={() => {
798-
void openDeployDialog('cloudflare')
799-
}}
800-
disabled={!canUseFinalActions}
801-
className="border-[#F48120] bg-[#F48120] text-white hover:bg-[#E67210]"
802-
>
803-
<Rocket className="h-4 w-4" />
804-
Deploy to Cloudflare
805-
</Button>
806-
807-
<Button
808-
size="sm"
809-
type="button"
810-
onClick={() => void openNetlifyStart()}
811-
disabled={!canUseFinalActions}
812-
className="border-[#00AD9F] bg-[#00AD9F] text-white hover:bg-[#009a8e]"
891+
<div className="flex flex-col gap-1.5">
892+
<div className="px-1 text-[10px] font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">
893+
Deploy to
894+
</div>
895+
<div
896+
aria-label="Deploy to"
897+
className="flex flex-wrap items-center gap-2"
898+
role="group"
813899
>
814-
{isGeneratingNetlify ? (
815-
<Loader2 className="h-4 w-4 animate-spin" />
816-
) : (
817-
<Rocket className="h-4 w-4" />
900+
{orderedDeploymentProviders.map((provider) =>
901+
renderDeploymentProviderButton(provider),
818902
)}
819-
{secondaryActionLabel}
820-
</Button>
821-
822-
<Button
823-
size="sm"
824-
type="button"
825-
onClick={() => {
826-
void openDeployDialog('railway')
827-
}}
828-
disabled={!canUseFinalActions}
829-
className="border-[#7C66FF] bg-[#7C66FF] text-white hover:bg-[#6A54F0]"
830-
>
831-
<Rocket className="h-4 w-4" />
832-
Deploy to Railway
833-
</Button>
903+
</div>
834904

835905
{!showMoreActions ? (
836906
<Button
837-
variant="secondary"
838-
size="sm"
907+
variant="ghost"
908+
size="xs"
839909
type="button"
840910
onClick={() => setShowMoreActions(true)}
911+
className="h-auto self-start border-transparent bg-transparent px-1 py-0 text-[11px] font-medium text-gray-500 hover:bg-transparent hover:text-gray-700 dark:text-gray-500 dark:hover:bg-transparent dark:hover:text-gray-300"
841912
>
842-
<ChevronDown className="h-4 w-4" />
843913
Show More
844914
</Button>
845915
) : null}
846-
</>
916+
</div>
847917
) : (
848918
<>
849919
<Button
@@ -992,6 +1062,10 @@ export function ApplicationStarter({
9921062
</Button>
9931063
</div>
9941064
) : null}
1065+
1066+
<div className="flex flex-wrap items-center gap-3">
1067+
{renderGeneratePromptButton()}
1068+
</div>
9951069
</div>
9961070
</div>
9971071
</CollapsibleContent>

0 commit comments

Comments
 (0)