Skip to content

Commit 409d5f5

Browse files
ralyodioclaude
andauthored
pit: buy an unclaimed name from the page someone landed on (#148)
/n/<name> already told a visitor the name was unclaimed and then left them to find the pit, work out which ending it belongs to, and mint it there. The purchase flow existed the whole time; it just was not reachable from the one page where someone has already demonstrated they want the name. The price comes from quoteName — the same call the checkout makes — so a button is only shown when the click behind it would actually succeed. A name that is reserved in someone else's open checkout, or already sold between the page rendering and the button being pressed, does not get one. Every reason you cannot buy says which reason it is. "Not for sale", "sign in first", "you own this ending", and "nobody holds the ending either" are four different problems with four different next steps, and a single greyed-out button would tell you none of them. The last one is the interesting case: it offers the whole ending instead, because at that point the name is free to whoever takes the namespace. Your own ending offers nothing to pay for — minting under it is free, and charging for that would be selling someone what they already have. Rendered every branch against a live server: priced ending, own ending, unpriced ending, unclaimed ending, and signed out. 279 across the pwa suite. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1137e0a commit 409d5f5

1 file changed

Lines changed: 59 additions & 4 deletions

File tree

apps/pwa/src/routes/moshpit.mjs

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,19 @@ moshpitRouter.get("/n/:name", async (req, res) => {
222222
tld ? listNames(tld) : Promise.resolve([]),
223223
listTlds(200),
224224
]);
225-
const owner = tld ? await getTld(tld) : null;
225+
const owner = tld ? await getTldWithPrice(tld) : null;
226+
227+
// Quoted rather than assumed: quoteName is the same call the checkout makes,
228+
// so an offer shown here is one the next click can actually honour — no
229+
// button for a name that is reserved, already sold, or on an ending whose
230+
// owner never set a price.
231+
const quote = !resolution.name_registered && req.user && tld
232+
? await quoteName({ tld, label: parsed.label, buyerId: req.user.id })
233+
: null;
234+
226235
res.status(resolution.name_registered ? 200 : 404).send(page({
227236
title: resolution.name,
228-
body: directory({ resolution, tld, owner, names, tlds }),
237+
body: directory({ resolution, tld, owner, names, tlds, quote, user: req.user, req }),
229238
}));
230239
});
231240

@@ -284,7 +293,7 @@ const unreachable = (resolution, why) => `
284293
* answer is what does. Live sites first — they are the only entries that go
285294
* anywhere real — then the rest of the ending, then other endings.
286295
*/
287-
function directory({ resolution, tld, owner, names, tlds }) {
296+
function directory({ resolution, tld, owner, names, tlds, quote, user, req }) {
288297
const live = names.filter((n) => n.target);
289298
const claimed = names.filter((n) => !n.target);
290299

@@ -306,8 +315,9 @@ function directory({ resolution, tld, owner, names, tlds }) {
306315
<p class="dim">
307316
${resolution.name_registered
308317
? "This name is claimed but does not point anywhere yet."
309-
: `Nobody holds this name. <a class="acid" href="/pit">Claim it →</a>`}
318+
: "Nobody holds this name."}
310319
</p>
320+
${resolution.name_registered ? "" : buyBox({ resolution, tld, owner, quote, user, req })}
311321
312322
${live.length ? `
313323
<h2 class="acid" style="font-size:.9rem;margin-top:26px">Sites on .${esc(tld)}</h2>
@@ -330,6 +340,49 @@ function directory({ resolution, tld, owner, names, tlds }) {
330340
</section>`;
331341
}
332342

343+
/**
344+
* The offer on an unclaimed name.
345+
*
346+
* Every branch here is a different reason someone cannot just buy it, and each
347+
* says which — "not for sale" and "sign in first" and "you own this ending" are
348+
* three different problems and a single greyed-out button would tell you none
349+
* of them.
350+
*
351+
* The price comes from a real quote, so the button is only shown when the
352+
* checkout behind it would succeed.
353+
*/
354+
function buyBox({ resolution, tld, owner, quote, user, req }) {
355+
const name = esc(resolution.name);
356+
357+
if (!user) {
358+
return `<p class="pit-buy"><a class="btn acid" href="/">Sign in to claim ${name} →</a></p>`;
359+
}
360+
if (owner && owner.user_id === user.id) {
361+
// Your own ending: minting is free, so offering to sell it to you would be
362+
// charging for something you already have.
363+
return `<p class="pit-buy">
364+
<span class="dim">.${esc(tld)} is yours — mint this name for nothing.</span>
365+
<a class="btn acid" href="/pit?tab=yours">the pit →</a></p>`;
366+
}
367+
if (!owner) {
368+
return `<p class="pit-buy">
369+
<span class="dim">Nobody holds .${esc(tld)} either.</span>
370+
<a class="btn acid" href="/pit">Claim the whole ending →</a></p>`;
371+
}
372+
if (!quote?.ok) {
373+
const why = quote?.taken ? esc(quote.error) : `.${esc(tld)} is not for sale`;
374+
return `<p class="pit-buy"><span class="dim">${why}.</span></p>`;
375+
}
376+
377+
return `
378+
<form method="post" action="/pit/${esc(tld)}/buy" class="pit-buy">
379+
${csrfInput(req)}
380+
<input type="hidden" name="label" value="${esc(quote.label)}">
381+
<button class="btn acid" type="submit">Buy ${name} — $${esc(String(quote.priceUsd))}</button>
382+
<span class="mono faint" style="font-size:.7rem">one-time, paid in crypto via CoinPay</span>
383+
</form>`;
384+
}
385+
333386
/* ---- the keys a name may present ---- */
334387

335388
/**
@@ -701,6 +754,8 @@ const PIT_CSS = `
701754
.pit-defaults label{display:flex;align-items:center;gap:6px;font-family:var(--mono);
702755
font-size:.72rem;letter-spacing:.06em;color:var(--dim);white-space:nowrap}
703756
.pit-defaults input{width:11ch;padding:7px 9px;font-size:.78rem}
757+
.pit-buy{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:16px 0 4px;
758+
padding:14px;border:1px solid var(--line);border-radius:8px;background:var(--bg)}
704759
.pit-dir{list-style:none;padding:0;margin:10px 0;display:grid;gap:4px}
705760
.pit-dir li{font-size:.82rem}
706761
.pit-dir-row{line-height:2;max-width:70ch}

0 commit comments

Comments
 (0)