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..00cd5e58fd --- /dev/null +++ b/apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts @@ -0,0 +1,122 @@ +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/rate-limit', () => ({ + rateLimit: () => 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..0b240909db 100644 --- a/apps/self-hosted/hosting/api/src/routes/tenants.ts +++ b/apps/self-hosted/hosting/api/src/routes/tenants.ts @@ -6,6 +6,8 @@ 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 { rateLimit } from '../middleware/rate-limit'; import { db } from '../db/client'; import { TenantService, @@ -33,6 +35,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 +63,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(), @@ -210,8 +216,15 @@ export async function resolveAndValidateTenant( return { ok: true, owner }; } +// Creation is unauthenticated by design (a personal blog's owner IS the requested +// account), so the same-owner refresh below is only as strong as this throttle: +// a tight per-IP budget keeps "loop the POST to rewrite someone's unpaid +// reservation" to a crawl while never blocking a human signing up. +const createLimit = rateLimit({ name: 'tenant-create', limit: 10, windowMs: 60_000 }); + tenantRoutes.post( '/', + createLimit, zValidator('json', createTenantSchema), // Use the same target reservation as paid /subscribe. Otherwise this unpaid create can insert // after the paid request's availability check but before its post-settlement tenant insert. @@ -224,8 +237,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 +260,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..15156137a6 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 @@ -81,10 +81,19 @@ describe('TenantService.create (revives abandoned reservations)', () => { // Resume branch: an existing same-owner inactive reservation refreshes its grace clock so an // 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 grace clock refreshes only for a composed submission or a reclaim: an + // overrides-less POST must not be able to pin a name inactive forever, out + // of the abandoned sweep's reach. + expect(sql).toMatch(/created_at = CASE WHEN tenants\.subscription_status = 'abandoned' OR \$5/); + // The owner is only overwritten for the abandoned (reclaim) branch. The CONFIG takes the + // new submission when the caller actually composed one ($5, the customize step) and also + // on reclaim; an overrides-less create keeps a saved reservation intact instead of wiping + // it back to defaults. + expect(sql).toMatch(/owner = CASE WHEN tenants\.subscription_status = 'abandoned'/); + expect(sql).toMatch(/config = CASE WHEN tenants\.subscription_status = 'abandoned' OR \$5/); expect(params[3]).toBe(ABANDONED_REREGISTER_QUARANTINE_HOURS); + // This call passed no overrides, so the refresh flag must be false. + expect(params[4]).toBe(false); }); it('throws a conflict when the upsert returns no row (a live or other-owner tenant holds the name)', async () => { @@ -146,3 +155,30 @@ describe('TenantService.getByOwner', () => { expect(sql).toMatch(/subscription_status != 'abandoned'/); }); }); + +describe('TenantService.create config-refresh flag', () => { + beforeEach(() => mocks.queryOne.mockReset()); + + const row = { + id: '1', + username: 'demo', + owner: 'demo', + subscription_status: 'inactive', + subscription_plan: 'standard', + config: {}, + }; + + it('structural keys alone do not refresh a saved reservation', async () => { + mocks.queryOne.mockResolvedValueOnce(row); + await TenantService.create('demo', 'demo', { theme: 'system' }); + const [, params] = mocks.queryOne.mock.calls[mocks.queryOne.mock.calls.length - 1]; + expect(params[4]).toBe(false); + }); + + it('a composed field refreshes it', async () => { + mocks.queryOne.mockResolvedValueOnce(row); + await TenantService.create('demo', 'demo', { theme: 'system', styleTemplate: 'magazine' }); + const [, params] = mocks.queryOne.mock.calls[mocks.queryOne.mock.calls.length - 1]; + expect(params[4]).toBe(true); + }); +}); 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..a5394d6527 100644 --- a/apps/self-hosted/hosting/api/src/services/tenant-service.ts +++ b/apps/self-hosted/hosting/api/src/services/tenant-service.ts @@ -220,33 +220,49 @@ 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. The CONFIG is taken only when the caller actually composed one ($5): the + // signup UI promises the look on screen is the look that activates, so a re-submission + // with overrides wins, while an overrides-less create (an API probe, or any legacy + // caller that only reserves) keeps the stored reservation intact instead of wiping a + // saved customization back to defaults. // // 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. + // Only fields the customize step actually expresses count as a composed + // config. Structural keys ride along on every create (the client always + // sends theme, a community always carries type and id), and counting them + // would make a skip-everything re-reserve wipe a saved customization. + const { + theme: _theme, + type: _type, + communityId: _communityId, + ...composed + } = configOverrides ?? {}; + const hasOverrides = Object.values(composed).some( + (value) => value !== undefined, + ); const row = await db.queryOne( `INSERT INTO tenants (username, owner, config, subscription_status, subscription_plan) VALUES ($1, $2, $3, 'inactive', 'standard') 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' + config = CASE WHEN tenants.subscription_status = 'abandoned' OR $5 THEN EXCLUDED.config ELSE tenants.config END, subscription_plan = 'standard', subscription_status = 'inactive', - created_at = NOW(), + created_at = CASE WHEN tenants.subscription_status = 'abandoned' OR $5 + THEN NOW() ELSE tenants.created_at END, updated_at = NOW() WHERE (tenants.subscription_status = 'abandoned' AND tenants.updated_at < NOW() - ($4 * INTERVAL '1 hour') AND ${CAUGHT_UP_SQL}) OR (tenants.subscription_status = 'inactive' AND tenants.owner = EXCLUDED.owner) RETURNING *`, - [username.toLowerCase(), ownerName, JSON.stringify(config), ABANDONED_REREGISTER_QUARANTINE_HOURS] + [username.toLowerCase(), ownerName, JSON.stringify(config), ABANDONED_REREGISTER_QUARANTINE_HOURS, hasOverrides] ); if (!row) { @@ -1212,6 +1228,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..56a70f66fb --- /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..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() {

)} - )} - {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 +830,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..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

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

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