From 016e736507f10b5ed011fe303a02934aa4b42711 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 11 Aug 2026 21:51:26 +0000 Subject: [PATCH 1/4] hosting: customize step before payment Signup asked only title and description; theme was hardcoded and every new instance started visually identical. The configure step becomes a customize step: template cards rendered from a new GET /v1/templates catalog (roster + display metadata, so the signup can never carry its own template list), accent quick picks plus a validated free hex field that blocks Continue while invalid, a font pairing select, and identity prefilled from the account profile into empty fields only, taken back out again if the name changes before payment. Choices persist per name in localStorage so an abandoned tab resumes, and are cleared on success. Skipping every choice produces exactly the previous default payload. Server side: createTenantSchema and the flat PATCH vocabulary accept accent (#rgb/#rrggbb) and fontPreset (closed key set shared with the SPA via appearance.ts, with a lockstep test against FONT_PRESETS); normalizeFlatOverrides maps them under general.styles without planting an empty styles object. The reservation lifecycle now honors the step's promise that the look on screen is the look that activates: a same-owner unpaid reservation is refreshed by re-creation (route lets it through after ownership validation; the upsert takes the new config), and the client re-sends creation whenever the name or the composed config changed instead of guarding on the name alone. Live tenants and other owners' reservations still 409. Closes #1414 --- .../self-hosted/hosting/api/src/appearance.ts | 28 ++ apps/self-hosted/hosting/api/src/index.ts | 3 + .../hosting/api/src/routes/templates.test.ts | 35 +++ .../hosting/api/src/routes/templates.ts | 14 + .../src/routes/tenant-create-refresh.test.ts | 119 +++++++ .../hosting/api/src/routes/tenants.ts | 23 +- .../api/src/services/flat-overrides.test.ts | 50 +++ .../services/tenant-service-cleanup.test.ts | 8 +- .../api/src/services/tenant-service.ts | 22 +- .../hosting/api/src/style-template-display.ts | 92 ++++++ .../src/styles/style-template-roster.test.ts | 18 ++ .../features/hosting-signup/accent-picker.tsx | 66 ++++ .../features/hosting-signup/hosting-api.ts | 25 ++ .../hosting-signup/hosting-signup.tsx | 233 ++++++++++++-- .../hosting-signup/template-picker.tsx | 83 +++++ apps/web/src/features/i18n/locales/en-US.json | 14 + .../hosting-signup/hosting-signup.spec.tsx | 291 +++++++++++++++++- 17 files changed, 1081 insertions(+), 43 deletions(-) create mode 100644 apps/self-hosted/hosting/api/src/appearance.ts create mode 100644 apps/self-hosted/hosting/api/src/routes/templates.test.ts create mode 100644 apps/self-hosted/hosting/api/src/routes/templates.ts create mode 100644 apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts create mode 100644 apps/self-hosted/hosting/api/src/services/flat-overrides.test.ts create mode 100644 apps/self-hosted/hosting/api/src/style-template-display.ts create mode 100644 apps/web/src/features/hosting-signup/accent-picker.tsx create mode 100644 apps/web/src/features/hosting-signup/template-picker.tsx diff --git a/apps/self-hosted/hosting/api/src/appearance.ts b/apps/self-hosted/hosting/api/src/appearance.ts new file mode 100644 index 0000000000..469686a943 --- /dev/null +++ b/apps/self-hosted/hosting/api/src/appearance.ts @@ -0,0 +1,28 @@ +/** + * Appearance constants shared with the blog SPA, next to the style template + * roster (style-templates.ts) and under the same rules: dependency-free, + * because the SPA bundles these, and living here because the API image builds + * from hosting/api alone and could not import in the other direction. + * + * The SPA's font preset definitions (src/core/theme-appearance.ts) carry the + * actual font stacks; the API needs only the closed key set for validating + * signup overrides. A lockstep test on the SPA side + * (src/styles/style-template-roster.test.ts) keeps the two agreeing. + */ +export const FONT_PRESET_KEYS = Object.freeze([ + 'classic', + 'editorial', + 'modern', + 'technical', + 'system', +] as const); + +export type FontPresetKey = (typeof FONT_PRESET_KEYS)[number]; + +/** + * `#rgb` or `#rrggbb`, the same shapes the SPA's parseHexColor accepts. The + * signup path validates and rejects; the Configuration Editor's full-document + * path stays lenient on purpose, because the SPA already treats an + * unparseable accent as "the template's own accent stands". + */ +export const ACCENT_HEX_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; diff --git a/apps/self-hosted/hosting/api/src/index.ts b/apps/self-hosted/hosting/api/src/index.ts index 70c381bc3c..e8ca3d1461 100644 --- a/apps/self-hosted/hosting/api/src/index.ts +++ b/apps/self-hosted/hosting/api/src/index.ts @@ -9,6 +9,7 @@ import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { secureHeaders } from 'hono/secure-headers'; import { tenantRoutes } from './routes/tenants'; +import { templateRoutes } from './routes/templates'; import { domainRoutes } from './routes/domains'; import { paymentRoutes } from './routes/payments'; import { authRoutes } from './routes/auth'; @@ -68,6 +69,7 @@ app.use('/v1/tenants/*', generalLimit); app.use('/v1/tenants', generalLimit); app.use('/v1/domains/*', generalLimit); app.use('/v1/payments/*', generalLimit); +app.use('/v1/templates', generalLimit); app.use('/v1/auth/*', generalLimit); app.use('/v1/auth/*', authLimit); @@ -85,6 +87,7 @@ app.use('/v1/internal/*', internalLimit); // API Routes app.route('/v1/tenants', tenantRoutes); +app.route('/v1/templates', templateRoutes); app.route('/v1/domains', domainRoutes); app.route('/v1/payments', paymentRoutes); app.route('/v1/auth', authRoutes); diff --git a/apps/self-hosted/hosting/api/src/routes/templates.test.ts b/apps/self-hosted/hosting/api/src/routes/templates.test.ts new file mode 100644 index 0000000000..f6d37272f8 --- /dev/null +++ b/apps/self-hosted/hosting/api/src/routes/templates.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { STYLE_TEMPLATES } from '../style-templates'; +import { templateRoutes } from './templates'; + +describe('GET /v1/templates', () => { + it('serves one card per roster entry with display fields and a single default', async () => { + const res = await templateRoutes.request('http://localhost/'); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toContain('max-age'); + + const body = (await res.json()) as { + templates: Array<{ + id: string; + name: string; + tagline: string; + isDefault: boolean; + colors: Record; + headingStyle: string; + }>; + }; + + expect(body.templates.map((t) => t.id).sort()).toEqual( + [...STYLE_TEMPLATES].sort(), + ); + expect(body.templates.filter((t) => t.isDefault)).toHaveLength(1); + for (const t of body.templates) { + expect(t.name.length).toBeGreaterThan(0); + expect(t.tagline.length).toBeGreaterThan(0); + for (const key of ['background', 'surface', 'accent', 'text']) { + expect(t.colors[key], `${t.id} colors.${key}`).toBeTruthy(); + } + expect(['serif', 'sans', 'mono']).toContain(t.headingStyle); + } + }); +}); diff --git a/apps/self-hosted/hosting/api/src/routes/templates.ts b/apps/self-hosted/hosting/api/src/routes/templates.ts new file mode 100644 index 0000000000..27ea15bb21 --- /dev/null +++ b/apps/self-hosted/hosting/api/src/routes/templates.ts @@ -0,0 +1,14 @@ +import { Hono } from 'hono'; +import { templateCatalog } from '../style-template-display'; + +/** + * Public template catalog for the signup UI's picker. Static data straight + * from the roster and its display map, so the signup can never carry its own + * copy of the template list. Cacheable: it changes only on deploy. + */ +export const templateRoutes = new Hono(); + +templateRoutes.get('/', (c) => { + c.header('Cache-Control', 'public, max-age=300'); + return c.json({ templates: templateCatalog() }); +}); diff --git a/apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts b/apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts new file mode 100644 index 0000000000..f6ad2391b0 --- /dev/null +++ b/apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ + existing: null as any, + created: [] as any[], +})); + +vi.mock('../db/client', () => ({ + db: { + // The audit logger chains .catch on these, so they must return promises. + query: vi.fn(async () => ({ rows: [] })), + queryOne: vi.fn(async () => null), + }, +})); +vi.mock('../middleware/auth', () => ({ + authMiddleware: async (_c: unknown, next: () => Promise) => next(), + adminMiddleware: async (_c: unknown, next: () => Promise) => next(), +})); +vi.mock('../middleware/payment-target-lock', () => ({ + withPaymentTargetLock: async ( + _c: unknown, + next: () => Promise, + ) => next(), +})); +vi.mock('../services/tenant-service', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isReregisterableAbandoned: () => false, + TenantService: { + ...actual.TenantService, + getByUsername: async () => state.existing, + isListenerCaughtUp: async () => true, + verifyHiveAccount: async () => true, + verifyCommunityControlledBy: async () => true, + create: async (username: string, owner: string, config: any) => { + state.created.push({ username, owner, config }); + return { + username, + owner, + subscriptionStatus: 'inactive', + subscriptionPlan: 'standard', + }; + }, + }, + }; +}); + +const { tenantRoutes } = await import('./tenants'); + +function post(body: unknown) { + return tenantRoutes.request('http://localhost/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /v1/tenants appearance validation', () => { + beforeEach(() => { + state.existing = null; + state.created = []; + }); + + it('rejects an accent that is not a hex color', async () => { + const res = await post({ username: 'alice', config: { accent: 'red' } }); + expect(res.status).toBe(400); + expect(state.created).toHaveLength(0); + }); + + it('rejects an unknown font preset', async () => { + const res = await post({ username: 'alice', config: { fontPreset: 'comic-sans' } }); + expect(res.status).toBe(400); + expect(state.created).toHaveLength(0); + }); + + it('accepts valid appearance values', async () => { + const res = await post({ + username: 'alice', + config: { styleTemplate: 'magazine', accent: '#ff6600', fontPreset: 'classic' }, + }); + expect(res.status).toBe(201); + expect(state.created[0].config.accent).toBe('#ff6600'); + }); +}); + +/** + * The customize step promises that the look on screen is the look that + * activates: a same-owner unpaid reservation is refreshed by re-creation + * (the latest submission wins), while anyone else's reservation and any + * live tenant stay 409. + */ +describe('POST /v1/tenants refreshes a same-owner unpaid reservation', () => { + beforeEach(() => { + state.existing = null; + state.created = []; + }); + + it('lets the owner re-submit an inactive reservation with a new look', async () => { + state.existing = { username: 'alice', owner: 'alice', subscriptionStatus: 'inactive' }; + const res = await post({ username: 'alice', config: { styleTemplate: 'developer' } }); + expect(res.status).toBe(201); + expect(state.created[0].config.styleTemplate).toBe('developer'); + }); + + it("still refuses someone else's inactive reservation", async () => { + state.existing = { username: 'alice', owner: 'mallory', subscriptionStatus: 'inactive' }; + const res = await post({ username: 'alice', config: { styleTemplate: 'developer' } }); + expect(res.status).toBe(409); + expect(state.created).toHaveLength(0); + }); + + it('still refuses a live tenant', async () => { + state.existing = { username: 'alice', owner: 'alice', subscriptionStatus: 'active' }; + const res = await post({ username: 'alice', config: {} }); + expect(res.status).toBe(409); + expect(state.created).toHaveLength(0); + }); +}); diff --git a/apps/self-hosted/hosting/api/src/routes/tenants.ts b/apps/self-hosted/hosting/api/src/routes/tenants.ts index b415589ae6..e87e69f37c 100644 --- a/apps/self-hosted/hosting/api/src/routes/tenants.ts +++ b/apps/self-hosted/hosting/api/src/routes/tenants.ts @@ -6,6 +6,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { zValidator } from '@hono/zod-validator'; import { STYLE_TEMPLATES } from '../style-templates'; +import { ACCENT_HEX_PATTERN, FONT_PRESET_KEYS } from '../appearance'; import { db } from '../db/client'; import { TenantService, @@ -33,6 +34,8 @@ const createTenantSchema = z.object({ config: z.object({ theme: z.enum(['light', 'dark', 'system']).optional(), styleTemplate: z.enum(STYLE_TEMPLATES).optional(), + accent: z.string().regex(ACCENT_HEX_PATTERN, 'accent must be #rgb or #rrggbb').optional(), + fontPreset: z.enum(FONT_PRESET_KEYS).optional(), type: z.enum(['blog', 'community']).optional(), communityId: z.string().optional(), title: z.string().max(100).optional(), @@ -59,6 +62,8 @@ const fullConfigDocSchema = z.object({ const flatConfigUpdateSchema = z.object({ theme: z.enum(['light', 'dark', 'system']).optional(), styleTemplate: z.enum(STYLE_TEMPLATES).optional(), + accent: z.string().regex(ACCENT_HEX_PATTERN, 'accent must be #rgb or #rrggbb').optional(), + fontPreset: z.enum(FONT_PRESET_KEYS).optional(), title: z.string().max(100).optional(), description: z.string().max(500).optional(), listType: z.enum(['list', 'grid']).optional(), @@ -224,8 +229,19 @@ tenantRoutes.post( // revives it below. Within the quarantine — or while the payment listener isn't confirmed // caught up to head (so a pending on-chain payment for it may be unprocessed) — it is still // treated as taken so an in-flight payment for it isn't overwritten. + // + // A same-owner 'inactive' reservation is NOT taken: that is the customize step re-submitting + // before any payment, and the latest submitted look must win (create() refreshes the stored + // config). The owner comparison happens after validation below, which is what enforces the + // ownership rules (a community's declared owner is checked on-chain; a personal blog's owner + // is the account itself, which is the same trust model first creation has always had). const existing = await TenantService.getByUsername(body.username); - if (existing && !(isReregisterableAbandoned(existing) && (await TenantService.isListenerCaughtUp()))) { + const sameNameInactive = !!existing && existing.subscriptionStatus === 'inactive'; + if ( + existing && + !sameNameInactive && + !(isReregisterableAbandoned(existing) && (await TenantService.isListenerCaughtUp())) + ) { return c.json({ error: 'Username already registered' }, 409); } @@ -236,6 +252,11 @@ tenantRoutes.post( } const { owner } = validation; + // An inactive reservation may only be refreshed by the owner it was reserved for. + if (sameNameInactive && existing!.owner !== owner) { + return c.json({ error: 'Username already registered' }, 409); + } + // Create tenant (inactive until payment). The served config file is NOT written here: // nginx serves any file that exists with no subscription check, so writing it now would // put an unpaid blog live forever. The file is generated only on activation (payment diff --git a/apps/self-hosted/hosting/api/src/services/flat-overrides.test.ts b/apps/self-hosted/hosting/api/src/services/flat-overrides.test.ts new file mode 100644 index 0000000000..3c18371071 --- /dev/null +++ b/apps/self-hosted/hosting/api/src/services/flat-overrides.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { TenantService } from './tenant-service'; + +// Pure mapping: no DB, no RPC. + +describe('normalizeFlatOverrides appearance keys', () => { + it('maps accent and fontPreset under general.styles, the paths the editor writes', () => { + const normalized = TenantService.normalizeFlatOverrides({ + styleTemplate: 'magazine', + accent: '#ff6600', + fontPreset: 'classic', + }); + + expect(normalized.configuration.general.styleTemplate).toBe('magazine'); + expect(normalized.configuration.general.styles).toEqual({ + accent: '#ff6600', + fontPreset: 'classic', + }); + }); + + it('writes no styles object at all when neither knob was sent', () => { + const normalized = TenantService.normalizeFlatOverrides({ + title: 'A blog', + }); + // An empty object would still merge and could shadow a stored section. + expect(normalized.configuration.general.styles).toBeUndefined(); + }); + + it('carries a single knob without inventing the other', () => { + const normalized = TenantService.normalizeFlatOverrides({ + accent: '#0af', + }); + expect(normalized.configuration.general.styles).toEqual({ + accent: '#0af', + }); + }); + + it('seeded config carries the signup appearance choices end to end', async () => { + const config = await TenantService.buildConfig( + 'alice', + { styleTemplate: 'developer', accent: '#89b4fa', fontPreset: 'technical' }, + 'alice', + ); + expect(config.configuration.general.styleTemplate).toBe('developer'); + expect(config.configuration.general.styles.accent).toBe('#89b4fa'); + expect(config.configuration.general.styles.fontPreset).toBe('technical'); + // The seed's own defaults survive alongside. + expect(config.configuration.general.theme).toBe('system'); + }); +}); diff --git a/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts b/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts index 192e1f01da..db920e47ab 100644 --- a/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts +++ b/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts @@ -82,8 +82,12 @@ describe('TenantService.create (revives abandoned reservations)', () => { // active checkout is not swept mid-payment. expect(sql).toMatch(/tenants\.subscription_status = 'inactive' AND tenants\.owner = EXCLUDED\.owner/); expect(sql).toMatch(/created_at = NOW\(\)/); - // Config/owner are only overwritten for the abandoned (reclaim) branch, never on a resume. - expect(sql).toMatch(/config = CASE WHEN tenants\.subscription_status = 'abandoned'/); + // The owner is only overwritten for the abandoned (reclaim) branch, but the CONFIG takes + // the new submission on BOTH branches: a same-owner unpaid reservation is the customize + // step re-submitting, and the latest look must win (silently keeping the old config is + // the bug this line used to pin as intended behavior). + expect(sql).toMatch(/owner = CASE WHEN tenants\.subscription_status = 'abandoned'/); + expect(sql).toMatch(/config = EXCLUDED\.config/); expect(params[3]).toBe(ABANDONED_REREGISTER_QUARANTINE_HOURS); }); diff --git a/apps/self-hosted/hosting/api/src/services/tenant-service.ts b/apps/self-hosted/hosting/api/src/services/tenant-service.ts index 97b70da1eb..0bc1e2e85d 100644 --- a/apps/self-hosted/hosting/api/src/services/tenant-service.ts +++ b/apps/self-hosted/hosting/api/src/services/tenant-service.ts @@ -220,12 +220,13 @@ export const TenantService = { // - 'abandoned' (past the re-registration quarantine): a fresh reservation RECLAIMS the name — // overwrite owner + config. The quarantine (updated_at older than the window) protects a row // whose earlier payment may still be in flight. - // - 'inactive' owned by the SAME owner: this is a re-entry into checkout for an existing - // reservation. REFRESH its grace clock (created_at = NOW) so the abandoned sweep can't - // reclaim it while it is actively being paid for — closing the window where an old reservation - // is swept mid-checkout and then overwritten before a slow payment (e.g. a card order ePoints - // retries with backoff for far longer than the quarantine) is finally recorded. Keep owner + - // config unchanged here (via CASE) so re-entry never overwrites an unpaid reservation's config. + // - 'inactive' owned by the SAME owner: this is the customize step re-submitting (or a + // re-entry into checkout) for an existing unpaid reservation. REFRESH its grace clock + // (created_at = NOW) so the abandoned sweep can't reclaim it while it is actively being + // paid for, and TAKE the newly submitted config: the reservation is unpaid and the signup + // UI promises that the look on screen is the look that activates, so the latest + // submission wins. (This used to keep the stored config, which silently discarded every + // re-customization.) // // Any other row (live tenant, or a different owner's inactive reservation) leaves the WHERE // unsatisfied, returns no row, and is surfaced as a conflict. A brand-new username inserts. @@ -235,8 +236,7 @@ export const TenantService = { ON CONFLICT (username) DO UPDATE SET owner = CASE WHEN tenants.subscription_status = 'abandoned' THEN EXCLUDED.owner ELSE tenants.owner END, - config = CASE WHEN tenants.subscription_status = 'abandoned' - THEN EXCLUDED.config ELSE tenants.config END, + config = EXCLUDED.config, subscription_plan = 'standard', subscription_status = 'inactive', created_at = NOW(), @@ -1212,6 +1212,12 @@ export const TenantService = { }; if (configOverrides.theme) normalized.configuration.general.theme = configOverrides.theme; if (configOverrides.styleTemplate) normalized.configuration.general.styleTemplate = configOverrides.styleTemplate; + // Appearance knobs land under general.styles, the same paths the editor writes. + if (configOverrides.accent || configOverrides.fontPreset) { + normalized.configuration.general.styles = {}; + if (configOverrides.accent) normalized.configuration.general.styles.accent = configOverrides.accent; + if (configOverrides.fontPreset) normalized.configuration.general.styles.fontPreset = configOverrides.fontPreset; + } if (configOverrides.type) normalized.configuration.instanceConfiguration.type = configOverrides.type; if (configOverrides.communityId) normalized.configuration.instanceConfiguration.communityId = configOverrides.communityId; // undefined means "not provided"; an explicit empty string clears the field. diff --git a/apps/self-hosted/hosting/api/src/style-template-display.ts b/apps/self-hosted/hosting/api/src/style-template-display.ts new file mode 100644 index 0000000000..2d84654ffc --- /dev/null +++ b/apps/self-hosted/hosting/api/src/style-template-display.ts @@ -0,0 +1,92 @@ +import { DEFAULT_STYLE_TEMPLATE, type StyleTemplate } from './style-templates'; + +/** + * Presentation metadata for the template picker (GET /v1/templates). The + * signup UI renders its cards from this, so the list can never drift from the + * roster: the map is `satisfies Record`, and adding a + * roster entry fails this file's typecheck until its card exists. + * + * Colors are the template's own light-mode tokens (bg-primary, bg-secondary, + * accent, text-primary from src/styles/themes/.css), duplicated here as + * plain values because the CSS lives in the SPA image and this API cannot + * read it. The roster guard suite keeps ids honest; these swatches are + * decorative and safe to lag a token tweak. + */ +export interface StyleTemplateDisplay { + name: string; + tagline: string; + colors: { + background: string; + surface: string; + accent: string; + text: string; + }; + /** Broad classification for the card's type sample, not a font stack. */ + headingStyle: 'serif' | 'sans' | 'mono'; +} + +export const STYLE_TEMPLATE_DISPLAY = { + medium: { + name: 'Medium', + tagline: 'Clean long-form reading with a classic serif voice', + colors: { + background: '#ffffff', + surface: '#fafafa', + accent: 'rgba(0, 0, 0, 0.84)', + text: 'rgba(0, 0, 0, 0.84)', + }, + headingStyle: 'serif', + }, + minimal: { + name: 'Minimal', + tagline: 'Quiet, spacious and out of the way of your words', + colors: { + background: '#ffffff', + surface: '#fafafa', + accent: '#0066cc', + text: '#1a1a1a', + }, + headingStyle: 'sans', + }, + magazine: { + name: 'Magazine', + tagline: 'Warm editorial look with display headlines', + colors: { + background: '#faf8f5', + surface: '#f5f2ed', + accent: '#8b4513', + text: '#2c2825', + }, + headingStyle: 'serif', + }, + developer: { + name: 'Developer', + tagline: 'Dark, code-friendly and easy on late-night eyes', + colors: { + background: '#1e1e2e', + surface: '#181825', + accent: '#89b4fa', + text: '#cdd6f4', + }, + headingStyle: 'mono', + }, + 'modern-gradient': { + name: 'Modern', + tagline: 'Bright surfaces with a vivid accent', + colors: { + background: '#f8fafc', + surface: '#ffffff', + accent: '#7c3aed', + text: '#0f172a', + }, + headingStyle: 'sans', + }, +} satisfies Record; + +export function templateCatalog() { + return Object.entries(STYLE_TEMPLATE_DISPLAY).map(([id, display]) => ({ + id, + isDefault: id === DEFAULT_STYLE_TEMPLATE, + ...display, + })); +} diff --git a/apps/self-hosted/src/styles/style-template-roster.test.ts b/apps/self-hosted/src/styles/style-template-roster.test.ts index 3e0388c71e..e67d74d891 100644 --- a/apps/self-hosted/src/styles/style-template-roster.test.ts +++ b/apps/self-hosted/src/styles/style-template-roster.test.ts @@ -77,3 +77,21 @@ describe('style template roster', () => { } }); }); + +/** + * The API validates signup font presets against FONT_PRESET_KEYS + * (hosting/api/src/appearance.ts); the SPA's FONT_PRESETS carries the actual + * stacks. The two must name the same set or a preset accepted at signup + * renders as the template default, silently. + */ +describe('font preset keys stay in lockstep with the API', () => { + it('matches theme-appearance FONT_PRESETS exactly', async () => { + const { FONT_PRESET_KEYS } = await import( + '../../hosting/api/src/appearance' + ); + const { FONT_PRESETS } = await import('../core/theme-appearance'); + expect(Object.keys(FONT_PRESETS).sort()).toEqual( + [...FONT_PRESET_KEYS].sort(), + ); + }); +}); diff --git a/apps/web/src/features/hosting-signup/accent-picker.tsx b/apps/web/src/features/hosting-signup/accent-picker.tsx new file mode 100644 index 0000000000..225b1cca27 --- /dev/null +++ b/apps/web/src/features/hosting-signup/accent-picker.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { FormControl } from "@ui/input"; +import i18next from "i18next"; +import { ACCENT_HEX_PATTERN } from "./hosting-api"; + +interface Props { + /** The committed accent (valid hex), or null for the template's own. */ + value: string | null; + /** The raw field text, which may be mid-edit and invalid. */ + input: string; + onInput: (raw: string) => void; + onPick: (hex: string | null) => void; +} + +/** + * A short row of quick picks plus a free hex field. Curated rather than a + * wheel: one accent is the whole knob, and the instance derives hover and + * contrast from it, so any readable hue works. + */ +const QUICK_PICKS = ["#e74c3c", "#e67e22", "#1a8917", "#0066cc", "#7c3aed", "#e91e8c"]; + +export function AccentPicker({ value, input, onInput, onPick }: Props) { + const trimmed = input.trim(); + const invalid = trimmed.length > 0 && !ACCENT_HEX_PATTERN.test(trimmed); + + return ( +
+
+ {QUICK_PICKS.map((hex) => ( + + )} +
+ onInput(e.target.value)} + placeholder="#0066cc" + aria-invalid={invalid} + aria-label={i18next.t("hosting.accent-label")} + /> + {invalid &&

{i18next.t("hosting.accent-invalid")}

} +
+ ); +} 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..95e5508162 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,37 @@ 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; +} + +function readCustomizeDraft(name: string): CustomizeDraft | null { + try { + const raw = localStorage.getItem(customizeDraftKey(name)); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as CustomizeDraft) : null; + } 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 +101,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 +121,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 +178,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 +197,96 @@ export function HostingSignup() { return; } } - setStep("configure"); + // 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; + Promise.resolve() + .then(() => hostingApi.templates?.()) + .then((r) => { + if (r && Array.isArray(r.templates)) setTemplates(r.templates); + else setTemplatesFailed(true); + }) + .catch(() => setTemplatesFailed(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(""); + // 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 any)?.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 }) + ); + } 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 +297,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(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 +344,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 +358,15 @@ 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; + 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 @@ -576,17 +720,55 @@ export function HostingSignup() {

)} - )} - {step === "configure" && ( + {step === "customize" && (
-

- {i18next.t(isCommunity ? "hosting.configure-hint-community" : "hosting.configure-hint-blog")} -

+

{i18next.t("hosting.customize-hint")}

+ + + + + + { + setAccentInput(raw); + const trimmed = raw.trim(); + if (!trimmed) setAccent(null); + else if (ACCENT_HEX_PATTERN.test(trimmed)) setAccent(trimmed); + }} + onPick={(hex) => { + setAccent(hex); + setAccentInput(hex ?? ""); + }} + /> + + + + @@ -607,11 +789,14 @@ export function HostingSignup() { isCommunity ? "hosting.community-desc-placeholder" : "hosting.blog-desc-placeholder" )} /> + +

{i18next.t("hosting.changeable-later")}

+
-
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..a4bcb5fc63 --- /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

{i18next.t("hosting.template-load-failed")}

; + } + if (!templates) { + return ( +