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
103 changes: 103 additions & 0 deletions apps/web/src/features/hosting-signup/destination-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { type ChangeEvent } from "react";
import i18next from "i18next";
import { FormControl } from "@ui/input";
import { normalizeDomain } from "./self-host-bundle";

export type HostingDestination = "managed" | "self-host";

interface Props {
value: HostingDestination;
onChange: (value: HostingDestination) => void;
/** Where the owner will serve an independent deployment, if they know yet. */
domain: string;
onDomainChange: (value: string) => void;
disabled?: boolean;
}

const OPTIONS: {
id: HostingDestination;
titleKey: string;
bodyKey: string;
}[] = [
{
id: "managed",
titleKey: "hosting.destination-managed",
bodyKey: "hosting.destination-managed-hint"
},
{
id: "self-host",
titleKey: "hosting.destination-self",
bodyKey: "hosting.destination-self-hint"
}
];

/**
* Where the blog someone just customized will actually live: on Ecency's
* hosting, or on their own server. The self-host branch is free and creates
* nothing, so this choice sits INSIDE the customize step rather than after
* it: the reader sees both routes while they are still deciding, and the
* flow does not grow a click for the paid path everyone else takes.
*/
export function DestinationPicker({
value,
onChange,
domain,
onDomainChange,
disabled
}: Props) {
// Typed but unusable input gets said out loud rather than silently
// dropped: the bundle falls back to the placeholder domain, and someone
// who typed something deserves to know their value was not used.
const invalidDomain = domain.trim().length > 0 && normalizeDomain(domain) === null;

return (
<div className="flex flex-col gap-2">
<div role="radiogroup" aria-label={i18next.t("hosting.destination-label")} className="grid sm:grid-cols-2 gap-2">
{OPTIONS.map((option) => {
const selected = value === option.id;
return (
<button
key={option.id}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={() => onChange(option.id)}
className={`text-left rounded-2xl border p-3 transition-colors disabled:opacity-50 ${
selected
? "border-blue-dark-sky bg-blue-dark-sky-030"
: "border-[--border-color] hover:border-blue-dark-sky"
}`}
>
<div className="text-sm font-semibold">{i18next.t(option.titleKey)}</div>
<div className="text-xs opacity-75 mt-1">{i18next.t(option.bodyKey)}</div>
</button>
);
})}
</div>

{value === "self-host" && (
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold" htmlFor="self-host-domain">
{i18next.t("hosting.self-host-domain-label")}
</label>
<FormControl
id="self-host-domain"
type="text"
value={domain}
disabled={disabled}
onChange={(e: ChangeEvent<HTMLInputElement>) => onDomainChange(e.target.value)}
placeholder={i18next.t("hosting.self-host-domain-placeholder")}
/>
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
{invalidDomain ? (
<p className="text-xs text-red">{i18next.t("hosting.self-host-domain-invalid")}</p>
) : (
<p className="text-xs opacity-60">{i18next.t("hosting.self-host-domain-hint")}</p>
)}
</div>
)}
</div>
);
}
17 changes: 17 additions & 0 deletions apps/web/src/features/hosting-signup/hosting-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@ export const hostingApi = {
createTenant: (username: string, owner?: string, config?: HostingConfigInput) =>
post<CreateTenantResult>("/v1/tenants", { username, owner: owner ?? username, config }),

/**
* Build identity of the running platform: `{ version, sha }`. The
* self-host bundle pins the image tag from this, so it pins a build that
* demonstrably exists rather than a name composed in the client.
*/
health: () => get<{ version?: string; sha?: string }>("/health"),

/**
* Compose the config document for an INDEPENDENT deployment: the same
* builder tenant creation uses, but nothing is created, reserved or
* published, and the markers that only mean something on a managed
* instance are stripped server-side. Used by the self-host branch, which
* never calls createTenant.
*/
composeConfig: (username: string, config?: HostingConfigInput, owner?: string) =>
post<{ config: unknown }>("/v1/tools/compose-config", { username, owner, config }),

tenant: (username: string) => get<TenantInfo>(`/v1/tenants/${encodeURIComponent(username)}`),

/** All tenants an account controls (its personal blog and any communities). */
Expand Down
83 changes: 80 additions & 3 deletions apps/web/src/features/hosting-signup/hosting-signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
import { CustomDomainManager } from "./custom-domain-manager";
import { TemplatePicker } from "./template-picker";
import { AccentPicker } from "./accent-picker";
import { DestinationPicker, type HostingDestination } from "./destination-picker";
import { useDownloadSelfHostBundle } from "./use-download-self-host-bundle";
import { getAccountFullQueryOptions } from "@ecency/sdk";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
Expand Down Expand Up @@ -118,6 +120,11 @@ export function HostingSignup() {
const [fontPreset, setFontPreset] = useState<string | null>(null);
const [templates, setTemplates] = useState<HostingTemplate[] | null>(null);
const [templatesFailed, setTemplatesFailed] = useState(false);
// Where this blog will live. Managed is the default: the self-host branch
// is free and creates nothing, so it must be chosen deliberately, and
// nothing about it may touch the reservation the managed path makes.
const [destination, setDestination] = useState<HostingDestination>("managed");
const [selfHostDomain, setSelfHostDomain] = useState("");
const [months, setMonths] = useState(1);
// Custom domain add-on: switches to the $3/mo "prohosting" plan so the tenant activates on the
// internal pro plan and can attach a custom domain after checkout.
Expand Down Expand Up @@ -324,6 +331,48 @@ export function HostingSignup() {
const accentPending =
accentInput.trim().length > 0 && !ACCENT_HEX_PATTERN.test(accentInput.trim());

const {
download: downloadSelfHostBundle,
busy: bundleBusy,
error: bundleError
} = useDownloadSelfHostBundle();

/**
* The self-host branch. It composes the same document the managed path
* would create a tenant from, wraps it with the deployment files and hands
* the archive over. It calls NOTHING that reserves a name: no createTenant,
* and createdForRef is deliberately left alone, so switching back to
* managed afterwards still makes the reservation properly.
*/
const downloadBundle = useCallback(async () => {
await downloadSelfHostBundle({
username: tenantUsername,
// A community is shown by the site but owned by the signed-in account.
owner: isCommunity ? (activeUser?.username ?? undefined) : undefined,
domain: selfHostDomain.trim() || undefined,
config: {
theme: "system",
title: title.trim() || undefined,
description: description.trim() || undefined,
styleTemplate: styleTemplate ?? undefined,
accent: accent ?? undefined,
fontPreset: fontPreset ?? undefined,
...(isCommunity ? { type: "community" as const, communityId: tenantUsername } : {})
}
});
}, [
downloadSelfHostBundle,
tenantUsername,
isCommunity,
activeUser,
selfHostDomain,
title,
description,
styleTemplate,
accent,
fontPreset
]);

// Create the (inactive) tenant for the CURRENT username, then move to payment. Payment
// activates it. Re-creates when the username changed since the last creation.
const goPayment = useCallback(async () => {
Expand Down Expand Up @@ -888,13 +937,41 @@ export function HostingSignup() {

<p className="text-sm opacity-60">{i18next.t("hosting.changeable-later")}</p>

<label className="text-sm font-semibold">
{i18next.t("hosting.destination-label")}
</label>
<DestinationPicker
value={destination}
onChange={setDestination}
domain={selfHostDomain}
onDomainChange={setSelfHostDomain}
disabled={busy || bundleBusy}
/>
{bundleError && <div className="text-sm text-red">{bundleError}</div>}

<div className="flex gap-2">
<Button appearance="secondary" onClick={() => setStep("username")}>
{i18next.t("g.back")}
</Button>
<Button onClick={goPayment} disabled={busy || accentPending} isLoading={busy} full={true}>
{i18next.t("g.continue")}
</Button>
{destination === "self-host" ? (
<Button
onClick={downloadBundle}
disabled={bundleBusy || accentPending}
isLoading={bundleBusy}
full={true}
>
{i18next.t("hosting.download-bundle")}
</Button>
) : (
<Button
onClick={goPayment}
disabled={busy || accentPending}
isLoading={busy}
full={true}
>
{i18next.t("g.continue")}
</Button>
)}
</div>
</div>
)}
Expand Down
Loading
Loading