+ );
+}
diff --git a/apps/web/src/features/hosting-signup/hosting-api.ts b/apps/web/src/features/hosting-signup/hosting-api.ts
index 95535f70bd..60130a0bae 100644
--- a/apps/web/src/features/hosting-signup/hosting-api.ts
+++ b/apps/web/src/features/hosting-signup/hosting-api.ts
@@ -18,6 +18,10 @@ export interface HostingPaymentMethods {
export interface HostingConfigInput {
theme?: "light" | "dark" | "system";
styleTemplate?: string;
+ /** One hex color (#rgb or #rrggbb); the instance derives hover/contrast from it. */
+ accent?: string;
+ /** A font pairing key from the hosting API's closed set. */
+ fontPreset?: string;
title?: string;
description?: string;
/** Instance kind. Omit (or "blog") for a personal blog; "community" hosts a Hive community. */
@@ -26,6 +30,23 @@ export interface HostingConfigInput {
communityId?: string;
}
+/** One card in the template catalog served by the hosting API (GET /v1/templates). */
+export interface HostingTemplate {
+ id: string;
+ name: string;
+ tagline: string;
+ isDefault: boolean;
+ colors: { background: string; surface: string; accent: string; text: string };
+ headingStyle: "serif" | "sans" | "mono";
+}
+
+/**
+ * Client-side mirror of the hosting API's accent validation
+ * (hosting/api/src/appearance.ts). The server is authoritative; this only
+ * exists so the form can refuse an unusable value before submission.
+ */
+export const ACCENT_HEX_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
+
export interface CreateTenantResult {
tenant: { username: string; subscriptionStatus: string; blogUrl: string };
paymentInstructions: { to: string; amount: string; memo: string; note?: string };
@@ -83,6 +104,10 @@ export const hostingApi = {
paymentMethods: () => get("/v1/payments/methods"),
+ /** The template catalog for the signup picker. Served by the API so the
+ * list can never drift from what tenant creation accepts. */
+ templates: () => get<{ templates: HostingTemplate[] }>("/v1/templates"),
+
/**
* Create the (inactive) tenant. Payment then activates it. `username` is the tenant subdomain
* (the Hive user for a personal blog, or the community id for a community). `owner` is the Hive
diff --git a/apps/web/src/features/hosting-signup/hosting-signup.tsx b/apps/web/src/features/hosting-signup/hosting-signup.tsx
index b3576e90cb..c5e98d980a 100644
--- a/apps/web/src/features/hosting-signup/hosting-signup.tsx
+++ b/apps/web/src/features/hosting-signup/hosting-signup.tsx
@@ -16,10 +16,16 @@ import {
hostingSkuForMonths,
hostingProSkuForMonths,
isValidCommunityId,
+ ACCENT_HEX_PATTERN,
HOSTING_CUSTOM_DOMAIN_MONTHLY_USD,
- type HostingPaymentMethods
+ type HostingPaymentMethods,
+ type HostingTemplate
} from "./hosting-api";
import { CustomDomainManager } from "./custom-domain-manager";
+import { TemplatePicker } from "./template-picker";
+import { AccentPicker } from "./accent-picker";
+import { getAccountFullQueryOptions } from "@ecency/sdk";
+import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
// Lazy-load the card checkout so /hosting doesn't pull @stripe/stripe-js (which injects the
@@ -36,12 +42,46 @@ const HostingCardCheckout = dynamic(
}
);
-type Step = "username" | "configure" | "payment" | "success";
+type Step = "username" | "customize" | "payment" | "success";
type Method = "hbd" | "card";
type InstanceType = "blog" | "community";
const TERMS = [1, 3, 6, 12];
+/** Font pairing keys the hosting API accepts; labels live in i18n. */
+const FONT_PRESETS = ["classic", "editorial", "modern", "technical", "system"] as const;
+
+/** localStorage key for an in-progress customization, so an abandoned tab resumes. */
+const customizeDraftKey = (name: string) => `ecency:hosting:customize:${name}`;
+
+interface CustomizeDraft {
+ styleTemplate?: string | null;
+ accent?: string | null;
+ fontPreset?: string | null;
+ title?: string;
+ description?: string;
+}
+
+/** Older than any reservation grace window; a stale draft must not resurrect. */
+const DRAFT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
+
+function readCustomizeDraft(name: string): CustomizeDraft | null {
+ try {
+ const raw = localStorage.getItem(customizeDraftKey(name));
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== "object") return null;
+ const savedAt = (parsed as { savedAt?: number }).savedAt;
+ if (typeof savedAt === "number" && Date.now() - savedAt > DRAFT_MAX_AGE_MS) {
+ localStorage.removeItem(customizeDraftKey(name));
+ return null;
+ }
+ return parsed as CustomizeDraft;
+ } catch {
+ return null;
+ }
+}
+
// sessionStorage key for a one-click HBD payment that was broadcast but not yet confirmed. Lets a
// redirecting signer (or a page reload) resume polling for activation on return. Session-scoped so
// it never lingers past the tab.
@@ -70,6 +110,14 @@ export function HostingSignup() {
const [communityId, setCommunityId] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
+ // The look, chosen on the customize step. null everywhere means the default
+ // look: skipping the step entirely must produce exactly today's defaults.
+ const [styleTemplate, setStyleTemplate] = useState(null);
+ const [accent, setAccent] = useState(null);
+ const [accentInput, setAccentInput] = useState("");
+ const [fontPreset, setFontPreset] = useState(null);
+ const [templates, setTemplates] = useState(null);
+ const [templatesFailed, setTemplatesFailed] = useState(false);
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.
@@ -82,10 +130,12 @@ export function HostingSignup() {
const [busy, setBusy] = useState(false);
// Card confirmed -> the term/method are locked so a remount can't cancel the activation poll.
const [paying, setPaying] = useState(false);
- // The username we actually created a tenant for; if the user goes back and changes it,
- // we must create the new one before payment (a stale guard would let them pay for a blog
- // that was never created and never activates).
- const createdForRef = useRef("");
+ // What we last reserved: the name AND the exact config sent. Going back and changing
+ // anything (name, look, identity) must re-send createTenant before payment: the server
+ // refreshes a same-owner unpaid reservation with the latest submission, so the look on
+ // screen is the look that activates. A stale guard would either let the user pay for a
+ // blog that was never created, or silently activate an older look.
+ const createdForRef = useRef<{ name: string; payload: string } | null>(null);
// Expiry of an ALREADY-ACTIVE tenant captured when entering the payment step (renewal).
// "I've sent the payment" must then require the expiry to move FORWARD, otherwise a
// renewing owner sees "your blog is live" without any payment having landed.
@@ -137,7 +187,7 @@ export function HostingSignup() {
if (!canManageDomain && customDomain) setCustomDomain(false);
}, [canManageDomain, customDomain]);
- const goConfigure = () => {
+ const goCustomize = () => {
setError("");
if (isCommunity) {
// The owner (creator) must be logged in: the owner comes from the active account.
@@ -156,9 +206,118 @@ export function HostingSignup() {
return;
}
}
- setStep("configure");
+ // Appearance choices belong to the name they were made for: a different name
+ // starts from the defaults (its own draft, if any, repopulates below).
+ if (customizeForRef.current && customizeForRef.current !== tenantUsername) {
+ setStyleTemplate(null);
+ setAccent(null);
+ setAccentInput("");
+ setFontPreset(null);
+ }
+ customizeForRef.current = tenantUsername;
+ // The previous name's auto-prefilled identity must not ride into a different name's
+ // tenant: take back exactly what the prefill planted, if the user has not edited it.
+ // Computed locally so the draft restore below sees the cleared values, not the stale
+ // state of this closure.
+ let nextTitle = title;
+ let nextDescription = description;
+ const planted = plantedRef.current;
+ if (planted && planted.name !== tenantUsername) {
+ if (planted.title && nextTitle === planted.title) nextTitle = "";
+ if (planted.description && nextDescription === planted.description) nextDescription = "";
+ plantedRef.current = null;
+ }
+ // An abandoned tab resumes its customization for the same name.
+ const draft = readCustomizeDraft(tenantUsername);
+ if (draft) {
+ if (draft.styleTemplate !== undefined) setStyleTemplate(draft.styleTemplate);
+ if (draft.accent !== undefined) {
+ setAccent(draft.accent);
+ setAccentInput(draft.accent ?? "");
+ }
+ if (draft.fontPreset !== undefined) setFontPreset(draft.fontPreset);
+ if (draft.title && !nextTitle) nextTitle = draft.title;
+ if (draft.description && !nextDescription) nextDescription = draft.description;
+ }
+ setTitle(nextTitle);
+ setDescription(nextDescription);
+ setStep("customize");
};
+ // Template catalog, fetched once when the customize step is first shown. A failed
+ // fetch must not block signup: the picker explains and the instance starts on the
+ // default look. Optional call: an older service without the endpoint behaves like
+ // a failed fetch.
+ useEffect(() => {
+ if (step !== "customize" || templates || templatesFailed) return;
+ let cancelled = false;
+ Promise.resolve()
+ .then(() => hostingApi.templates?.())
+ .then((r) => {
+ if (cancelled) return;
+ // An empty catalog is a failure for the picker's purposes: an empty grid
+ // with no explanation is worse than the plain-form fallback.
+ if (r && Array.isArray(r.templates) && r.templates.length > 0) setTemplates(r.templates);
+ else setTemplatesFailed(true);
+ })
+ .catch(() => {
+ if (!cancelled) setTemplatesFailed(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [step, templates, templatesFailed]);
+
+ // Prefill identity from the account's profile so the step starts with the owner's
+ // own words instead of empty boxes. Only for a personal blog (a community's title
+ // is resolved server-side from the community record), only into EMPTY fields, and
+ // only once per name so typing is never fought.
+ const { data: prefillAccount } = useQuery({
+ ...getAccountFullQueryOptions(tenantUsername),
+ enabled: step === "customize" && !isCommunity && tenantUsername.length >= 3
+ });
+ const prefilledForRef = useRef("");
+ // Which name the current appearance state was composed for.
+ const customizeForRef = useRef("");
+ // What the prefill planted and for whom, so a NAME CHANGE can take it back out again:
+ // without this, bob's signup would silently carry alice's profile title and bio because
+ // the empty-field guard sees them as already filled.
+ const plantedRef = useRef<{ name: string; title: string; description: string } | null>(null);
+ useEffect(() => {
+ if (step !== "customize" || isCommunity || !prefillAccount) return;
+ if (prefilledForRef.current === tenantUsername) return;
+ prefilledForRef.current = tenantUsername;
+ const profile = (
+ prefillAccount as { profile?: { name?: unknown; about?: unknown } } | undefined
+ )?.profile;
+ const plantedTitle = profile?.name && !title ? String(profile.name).slice(0, 100) : "";
+ const plantedDescription =
+ profile?.about && !description ? String(profile.about).slice(0, 500) : "";
+ if (plantedTitle) setTitle(plantedTitle);
+ if (plantedDescription) setDescription(plantedDescription);
+ if (plantedTitle || plantedDescription) {
+ plantedRef.current = {
+ name: tenantUsername,
+ title: plantedTitle,
+ description: plantedDescription
+ };
+ }
+ }, [step, isCommunity, prefillAccount, tenantUsername, title, description]);
+
+ // Persist the in-progress customization per name.
+ useEffect(() => {
+ if (step !== "customize" || !tenantUsername) return;
+ try {
+ localStorage.setItem(
+ customizeDraftKey(tenantUsername),
+ JSON.stringify({ styleTemplate, accent, fontPreset, title, description, savedAt: Date.now() })
+ );
+ } catch {}
+ }, [step, tenantUsername, styleTemplate, accent, fontPreset, title, description]);
+
+ const accentPending =
+ accentInput.trim().length > 0 && !ACCENT_HEX_PATTERN.test(accentInput.trim());
+
// 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 () => {
@@ -169,14 +328,19 @@ export function HostingSignup() {
// blog account itself (which may pay by HBD while logged out).
const owner = isCommunity ? (activeUser?.username ?? "") : uname;
try {
- if (createdForRef.current !== uname) {
- const res = await hostingApi.createTenant(uname, owner, {
- theme: "system",
- title: title.trim() || undefined,
- description: description.trim() || undefined,
- ...(isCommunity ? { type: "community", communityId: uname } : {})
- });
- createdForRef.current = uname;
+ const config = {
+ theme: "system" as const,
+ title: title.trim() || undefined,
+ description: description.trim() || undefined,
+ styleTemplate: styleTemplate ?? undefined,
+ accent: accent ?? undefined,
+ fontPreset: fontPreset ?? undefined,
+ ...(isCommunity ? { type: "community" as const, communityId: uname } : {})
+ };
+ const payload = JSON.stringify({ owner, config });
+ if (createdForRef.current?.name !== uname || createdForRef.current?.payload !== payload) {
+ const res = await hostingApi.createTenant(uname, owner, config);
+ createdForRef.current = { name: uname, payload };
setBlogUrl(res.tenant.blogUrl);
renewBaselineExpiryRef.current = null; // freshly created, inactive
}
@@ -211,7 +375,10 @@ export function HostingSignup() {
setError(i18next.t("hosting.already-registered"));
return;
}
- createdForRef.current = uname;
+ // A 409 now only means an active/expired tenant (renewal) or someone else's
+ // reservation; unpaid same-owner reservations are refreshed above. Record the name
+ // with no payload so a later look change still attempts a fresh create.
+ createdForRef.current = { name: uname, payload: "" };
setBlogUrl(`https://${uname}.${baseDomain}`);
// Renewal of an already-active tenant: remember the current expiry so activation is only
// confirmed once it advances. null (an inactive tenant resuming its first payment) means
@@ -222,7 +389,18 @@ export function HostingSignup() {
} finally {
setBusy(false);
}
- }, [tenantUsername, isCommunity, title, description, activeUser]);
+ }, [tenantUsername, isCommunity, title, description, styleTemplate, accent, fontPreset, activeUser]);
+
+ // The reservation is made and paid for; the in-progress draft has served its purpose.
+ useEffect(() => {
+ if (step !== "success" || !tenantUsername) return;
+ // Guarded on the reservation this flow actually made or resumed: a pending
+ // payment recovered for a DIFFERENT tenant must not delete this name's draft.
+ if (createdForRef.current?.name !== tenantUsername) return;
+ try {
+ localStorage.removeItem(customizeDraftKey(tenantUsername));
+ } catch {}
+ }, [step, tenantUsername]);
// Deep-link from the "Your hosted sites" manage panel: ?resume=[&type=community] jumps
// straight to the payment step for an existing reservation, so an "Awaiting payment" tenant has a
@@ -257,6 +435,13 @@ export function HostingSignup() {
setInstanceType(isComm ? "community" : "blog");
if (isComm) setCommunityId(t.username);
else setUsername(t.username);
+ // Straight to payment WITHOUT createTenant: the reservation exists and its
+ // saved customization must survive a resume click. The sentinel payload can
+ // never equal a composed one, so going Back and actively re-customizing
+ // still re-sends creation, which is the one flow that should refresh it.
+ createdForRef.current = { name: t.username.toLowerCase(), payload: "\u0000resume" };
+ setBlogUrl(t.blogUrl || `https://${t.username.toLowerCase()}.${baseDomain}`);
+ renewBaselineExpiryRef.current = null; // inactive: first payment, not a renewal
setResumeName(t.username.toLowerCase());
})();
return () => {
@@ -266,9 +451,9 @@ export function HostingSignup() {
useEffect(() => {
if (resumeName && step === "username" && tenantUsername === resumeName && activeUser) {
setResumeName(null);
- void goPayment();
+ setStep("payment");
}
- }, [resumeName, step, tenantUsername, activeUser, goPayment]);
+ }, [resumeName, step, tenantUsername, activeUser]);
// Deep-link from an unclaimed *.blogs.ecency.com subdomain's claim landing: ?claim=
// prefills the form so the visitor arrives ready to reserve that exact name. A hive-
@@ -576,17 +761,55 @@ export function HostingSignup() {
diff --git a/apps/web/src/features/hosting-signup/template-picker.tsx b/apps/web/src/features/hosting-signup/template-picker.tsx
new file mode 100644
index 0000000000..dd84eea1db
--- /dev/null
+++ b/apps/web/src/features/hosting-signup/template-picker.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import i18next from "i18next";
+import type { HostingTemplate } from "./hosting-api";
+
+interface Props {
+ templates: HostingTemplate[] | null;
+ /** Load failure: signup must keep working; the instance starts on the default look. */
+ failed: boolean;
+ value: string | null;
+ onChange: (id: string | null) => void;
+}
+
+const HEADING_FONT: Record = {
+ serif: "font-serif",
+ sans: "font-sans",
+ mono: "font-mono"
+};
+
+/**
+ * The template choice as cards instead of a dropdown: each card is a small
+ * mock of the template built from its own palette (page, surface bar, accent
+ * dot, a type sample), so picking a look reads as picking a look. Selecting
+ * the already-selected card clears back to the default.
+ */
+export function TemplatePicker({ templates, failed, value, onChange }: Props) {
+ if (failed) {
+ return