Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/pwa/src/lib/moshpit-build-promo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// The build-and-host offer, drawn on /pit.
//
// Somebody who has just bought a name has a name and nothing at it. The
// hosting help explains how to point it at a server of their own; this is the
// other answer, for everyone without one: describe the app, chovy.com builds it
// on the Profullstack stack and keeps it online. The storefront is
// pit.world/build; the form here posts straight to the same intake it does, so
// one click from the pit skips a page.
import { esc } from "./html.mjs";

export const BUILD_URL = "https://pit.world/build";
export const BUILD_START_URL = "https://chovy.com/start";
export const BUILD_PRICE_USD = 49;
export const HOSTING_PRICE_USD = 2;

/**
* The name a `?ok=` banner just handed over, when it handed over exactly one.
*
* The buy and claim flows redirect with `ok=<name> is yours.`; that sentence is
* the only record on the page of what was bought, so the offer reads it rather
* than guessing at the newest row.
*/
export function boughtName(ok) {
const m = /^([a-z0-9-]+\.[a-z0-9-]+) is yours\.?$/i.exec(String(ok ?? "").trim());
return m ? m[1].toLowerCase() : null;
}

/**
* The card. `name` is the name just bought, when there is one: the offer then
* talks about that name and seeds the idea with it.
*/
export function buildPromo({ name = null } = {}) {
const forName = name ? ` for <span class="mono acid">${esc(name)}</span>` : "";
const seed = name ? `A site at ${name}: ` : "";
return `<section class="pit-build" aria-labelledby="pit-build-h">
<p class="label">no server? no problem</p>
<h2 id="pit-build-h">We build it${forName}. We host it.</h2>
<p class="pit-copy">Describe the app in plain words. An AI product team writes a plan you can argue
with, builds it on the open source <a class="acid" href="https://profullstack.com/stack">Profullstack
stack</a>, and shows you it working before anything launches.
<b>$${BUILD_PRICE_USD}</b> to build, including the first 30 days, then <b>$${HOSTING_PRICE_USD} a month</b>
to keep it online. Paid in crypto through CoinPay.</p>
<form class="pit-build-form" action="${BUILD_START_URL}" method="get">
<textarea name="idea" rows="3" maxlength="2000" required
aria-label="describe the app you want built"
placeholder="A booking page for my barbershop: customers pick a barber and a time, pay a deposit, and get a text reminder.">${esc(seed)}</textarea>
<input type="hidden" name="plan" value="starter">
<input type="hidden" name="ref" value="moshpit">
<div class="pit-row">
<button class="btn acid" type="submit">Build it for $${BUILD_PRICE_USD} →</button>
<a class="mono faint" style="font-size:.78rem" href="${BUILD_URL}">how build + hosting works</a>
</div>
</form>
</section>`;
}

export const BUILD_PROMO_CSS = `
.pit-build{border:1px solid var(--acid);border-radius:var(--r);background:color-mix(in srgb,var(--acid) 6%,var(--surface));
padding:18px 20px;margin:18px 0;max-width:72ch}
.pit-build h2{font-size:clamp(1.2rem,3.4vw,1.6rem);margin:4px 0 8px}
.pit-build b{color:var(--acid);font-weight:600}
.pit-build-form textarea{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);
border:1px solid var(--line-2);border-radius:8px;padding:10px 12px;font:inherit;font-size:.9rem;margin-top:10px;resize:vertical}
.pit-build-form textarea:focus{border-color:var(--acid);outline:0}
`;
9 changes: 7 additions & 2 deletions apps/pwa/src/routes/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { bearer, userForApiKey } from "../lib/apikey.mjs";
import { balance } from "../lib/credits.mjs";
import { resolverConfig } from "../lib/moshpit-resolvers.mjs";
import { landingFor } from "../lib/moshpit-landing.mjs";
import { buildPromo, boughtName, BUILD_PROMO_CSS, BUILD_URL, BUILD_PRICE_USD, HOSTING_PRICE_USD } from "../lib/moshpit-build-promo.mjs";
import { FEED_KINDS, loadFeed } from "../lib/feed.mjs";
import {
sendOfferAnswer,
Expand Down Expand Up @@ -2707,7 +2708,8 @@ const landingCard = (req, landing) => {
return `<div class="pit-land">
<p class="label">you asked for ${esc(landing.name)}</p>
<h2>${name} is already yours.</h2>
<p class="pit-copy">It is in your list below — change where it points, or release it.</p>
<p class="pit-copy">It is in your list below — change where it points, or release it.
Nothing at it yet? <a class="acid" href="${BUILD_URL}">We build and host it</a> — $${BUILD_PRICE_USD}, then $${HOSTING_PRICE_USD} a month.</p>
</div>`;
}

Expand Down Expand Up @@ -3123,6 +3125,8 @@ function hostingHelp() {
<code>moshcode template install bun-caddy-sqlite</code> writes the Caddyfile, the systemd
units and a Bun + SQLite service already wired up this way.
<code>moshcode template list</code> shows what there is.</p>
<p class="pit-copy" style="font-size:.84rem">No server, or no time? <a class="acid" href="${BUILD_URL}">We build the
app and host it for you</a> — $${BUILD_PRICE_USD} to build, then $${HOSTING_PRICE_USD} a month.</p>
<p class="pit-copy" style="font-size:.84rem">Check it with <code>moshcode dns resolve seo.rank</code>,
then <code>curl -6 http://seo.rank/</code>. A port other than 80 only works in the
<code>/n/</code> view — DNS carries an address and has nowhere to put a port, so the browser
Expand Down Expand Up @@ -3390,7 +3394,7 @@ moshpitRouter.get("/pit", async (req, res) => {

res.type("html").send(page({
title: "moshcode ▸ the pit",
head: `<style>${PIT_CSS}</style>`,
head: `<style>${PIT_CSS}${BUILD_PROMO_CSS}</style>`,
body: `${appBar(req.user, bal, req.csrfToken)}
<main class="wrap" style="padding:38px 24px 64px">
<p class="label">the moshpit namespace</p>
Expand All @@ -3410,6 +3414,7 @@ moshpitRouter.get("/pit", async (req, res) => {
</p>
${landingCard(req, landing)}
${msg}
${buildPromo({ name: boughtName(req.query.ok) })}
${req.user ? claimForm(req) + bulkClaimForm(req) : ""}
${pitTabs(tab, { yours: mineTotal, theirs: theirsTotal, forSale: filter ? 0 : forSaleCount }, filter?.query ?? "")}
${filterBox(tab, filter?.query ?? "", req.user ? (tab === "theirs" ? "theirs" : "mine") : "all")}
Expand Down
31 changes: 31 additions & 0 deletions apps/pwa/test/moshpit-build-promo.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// The build-and-host offer on /pit: it must reach the paid intake, and it must
// never echo a banner it was handed into the page unescaped.
import assert from "node:assert/strict";
import test from "node:test";
import { boughtName, buildPromo, BUILD_START_URL } from "../src/lib/moshpit-build-promo.mjs";

test("boughtName reads the name a buy or claim just handed over", () => {
assert.equal(boughtName("pay.agent is yours."), "pay.agent");
assert.equal(boughtName("Pay.Agent is yours"), "pay.agent");
// An ending, a count, or anything else is not one name.
assert.equal(boughtName(".agent is yours."), null);
assert.equal(boughtName("3 names claimed"), null);
assert.equal(boughtName(undefined), null);
});

test("the form posts to the chovy Starter intake, tagged as moshpit", () => {
const html = buildPromo();
assert.match(html, new RegExp(`action="${BUILD_START_URL}" method="get"`));
assert.match(html, /name="plan" value="starter"/);
assert.match(html, /name="ref" value="moshpit"/);
assert.match(html, /<textarea name="idea"[^>]*required/);
assert.match(html, /\$49/);
assert.match(html, /\$2 a month/);
assert.match(html, /href="https:\/\/pit\.world\/build"/);
});

test("a bought name seeds the idea and is escaped", () => {
assert.match(buildPromo({ name: "pay.agent" }), />A site at pay\.agent: <\/textarea>/);
const html = buildPromo({ name: "<x>.y" });
assert.doesNotMatch(html, /<x>/);
});
Loading