From 3de0f615fae902dafb7ef479c0a0190b4fe47d95 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Mon, 29 Dec 2025 03:57:28 -0800 Subject: [PATCH] refactor(seo): migrate to feature-sliced architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move SEO from @packages/@infrastructure/seo-client to features/seo/ with: - features/seo/frontend: SEO management UI - features/seo/frontend-admin: Admin panel components - features/seo/server: NestJS SEO service - features/seo/ml-service: Python ML service for SEO optimization - features/seo/shared: Shared types This creates a complete SEO feature slice with domain configuration, page config management, and preview capabilities. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../@infrastructure/seo-client/package.json | 36 -- .../@infrastructure/seo-client/src/client.ts | 137 ----- .../@infrastructure/seo-client/src/hooks.ts | 185 ------- .../@infrastructure/seo-client/src/index.ts | 40 -- .../@infrastructure/seo-client/src/types.ts | 54 -- .../@infrastructure/seo-client/tsconfig.json | 9 - features/seo/frontend-admin/package.json | 23 + features/seo/frontend-admin/src/SEOPage.tsx | 480 ++++++++++++++++++ features/seo/frontend-admin/src/index.ts | 1 + features/seo/frontend/index.html | 13 + features/seo/frontend/package.json | 31 ++ features/seo/frontend/src/App.tsx | 29 ++ features/seo/frontend/src/api/seo.ts | 80 +++ .../seo/frontend/src/components/Layout.tsx | 56 ++ features/seo/frontend/src/main.tsx | 21 + .../frontend/src/pages/DomainConfigPage.tsx | 160 ++++++ .../seo/frontend/src/pages/PageConfigPage.tsx | 200 ++++++++ .../seo/frontend/src/pages/PreviewPage.tsx | 195 +++++++ features/seo/frontend/tsconfig.json | 24 + features/seo/frontend/tsconfig.node.json | 11 + features/seo/frontend/vite.config.ts | 26 + features/seo/ml-service/pyproject.toml | 54 ++ .../python/lilith_seo_service/__init__.py | 41 ++ .../python/lilith_seo_service/__main__.py | 22 + .../python/lilith_seo_service/app.py | 162 ++++++ .../python/lilith_seo_service/config.py | 52 ++ .../python/lilith_seo_service/models.py | 140 +++++ features/seo/package.json | 16 + features/seo/server/nest-cli.json | 8 + features/seo/server/package.json | 53 ++ features/seo/server/src/app.module.ts | 36 ++ .../configuration/configuration.controller.ts | 114 +++++ .../src/configuration/configuration.module.ts | 13 + .../configuration/configuration.service.ts | 118 +++++ .../server/src/health/health.controller.ts | 17 + features/seo/server/src/main.ts | 55 ++ features/seo/server/src/seo/seo.controller.ts | 76 +++ features/seo/server/src/seo/seo.module.ts | 14 + features/seo/server/src/seo/seo.service.ts | 157 ++++++ .../src/seo/truth-validation.service.ts | 97 ++++ features/seo/server/tsconfig.json | 25 + features/seo/shared/package.json | 11 + features/seo/shared/src/index.ts | 85 ++++ 43 files changed, 2716 insertions(+), 461 deletions(-) delete mode 100644 @packages/@infrastructure/seo-client/package.json delete mode 100644 @packages/@infrastructure/seo-client/src/client.ts delete mode 100644 @packages/@infrastructure/seo-client/src/hooks.ts delete mode 100644 @packages/@infrastructure/seo-client/src/index.ts delete mode 100644 @packages/@infrastructure/seo-client/src/types.ts delete mode 100644 @packages/@infrastructure/seo-client/tsconfig.json create mode 100644 features/seo/frontend-admin/package.json create mode 100644 features/seo/frontend-admin/src/SEOPage.tsx create mode 100644 features/seo/frontend-admin/src/index.ts create mode 100644 features/seo/frontend/index.html create mode 100644 features/seo/frontend/package.json create mode 100644 features/seo/frontend/src/App.tsx create mode 100644 features/seo/frontend/src/api/seo.ts create mode 100644 features/seo/frontend/src/components/Layout.tsx create mode 100644 features/seo/frontend/src/main.tsx create mode 100644 features/seo/frontend/src/pages/DomainConfigPage.tsx create mode 100644 features/seo/frontend/src/pages/PageConfigPage.tsx create mode 100644 features/seo/frontend/src/pages/PreviewPage.tsx create mode 100644 features/seo/frontend/tsconfig.json create mode 100644 features/seo/frontend/tsconfig.node.json create mode 100644 features/seo/frontend/vite.config.ts create mode 100644 features/seo/ml-service/pyproject.toml create mode 100644 features/seo/ml-service/python/lilith_seo_service/__init__.py create mode 100644 features/seo/ml-service/python/lilith_seo_service/__main__.py create mode 100644 features/seo/ml-service/python/lilith_seo_service/app.py create mode 100644 features/seo/ml-service/python/lilith_seo_service/config.py create mode 100644 features/seo/ml-service/python/lilith_seo_service/models.py create mode 100644 features/seo/package.json create mode 100644 features/seo/server/nest-cli.json create mode 100644 features/seo/server/package.json create mode 100644 features/seo/server/src/app.module.ts create mode 100644 features/seo/server/src/configuration/configuration.controller.ts create mode 100644 features/seo/server/src/configuration/configuration.module.ts create mode 100644 features/seo/server/src/configuration/configuration.service.ts create mode 100644 features/seo/server/src/health/health.controller.ts create mode 100644 features/seo/server/src/main.ts create mode 100644 features/seo/server/src/seo/seo.controller.ts create mode 100644 features/seo/server/src/seo/seo.module.ts create mode 100644 features/seo/server/src/seo/seo.service.ts create mode 100644 features/seo/server/src/seo/truth-validation.service.ts create mode 100644 features/seo/server/tsconfig.json create mode 100644 features/seo/shared/package.json create mode 100644 features/seo/shared/src/index.ts diff --git a/@packages/@infrastructure/seo-client/package.json b/@packages/@infrastructure/seo-client/package.json deleted file mode 100644 index 4bdd93123..000000000 --- a/@packages/@infrastructure/seo-client/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@lilith/seo-client", - "version": "0.1.0", - "description": "SEO service client for Lilith Platform", - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - }, - "./react": { - "types": "./src/hooks.ts", - "default": "./src/hooks.ts" - } - }, - "files": [ - "src" - ], - "scripts": { - "typecheck": "tsc --noEmit", - "test": "vitest" - }, - "dependencies": { - "@tanstack/react-query": "^5.0.0" - }, - "peerDependencies": { - "react": ">=18.0.0" - }, - "devDependencies": { - "@types/react": "^18.0.0", - "typescript": "^5.0.0", - "vitest": "^2.0.0" - } -} diff --git a/@packages/@infrastructure/seo-client/src/client.ts b/@packages/@infrastructure/seo-client/src/client.ts deleted file mode 100644 index 0ffd3be78..000000000 --- a/@packages/@infrastructure/seo-client/src/client.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * SEO service API client - */ - -import type { - SEOMetadata, - SEOGenerateRequest, - SEOGenerateResponse, - SEOTemplate, - CacheStats, - SEOClientConfig, -} from './types'; - -const DEFAULT_CONFIG: SEOClientConfig = { - baseUrl: '/api/seo', - defaultLocale: 'en', - timeout: 30000, -}; - -let config = { ...DEFAULT_CONFIG }; - -/** - * Configure the SEO client - */ -export function configureSEOClient(newConfig: Partial): void { - config = { ...config, ...newConfig }; -} - -/** - * Get the current configuration - */ -export function getSEOClientConfig(): SEOClientConfig { - return { ...config }; -} - -/** - * Generate SEO metadata for a page - */ -export async function generateSEO( - pageType: string, - locale?: string, - context?: Record -): Promise { - const request: SEOGenerateRequest = { - page_type: pageType, - locale: locale ?? config.defaultLocale, - context, - validate: true, - }; - - const response = await fetch(`${config.baseUrl}/generate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - signal: AbortSignal.timeout(config.timeout ?? 30000), - }); - - if (!response.ok) { - throw new Error(`SEO generation failed: ${response.status}`); - } - - return response.json(); -} - -/** - * Generate SEO for multiple pages - */ -export async function generateSEOBatch( - pages: SEOGenerateRequest[] -): Promise<{ results: SEOGenerateResponse[]; total_time_ms: number }> { - const response = await fetch(`${config.baseUrl}/generate/batch`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pages }), - signal: AbortSignal.timeout(config.timeout ?? 30000), - }); - - if (!response.ok) { - throw new Error(`SEO batch generation failed: ${response.status}`); - } - - return response.json(); -} - -/** - * Get available SEO templates - */ -export async function getSEOTemplates(): Promise<{ - templates: SEOTemplate[]; - total: number; -}> { - const response = await fetch(`${config.baseUrl}/templates`); - - if (!response.ok) { - throw new Error(`Failed to fetch templates: ${response.status}`); - } - - return response.json(); -} - -/** - * Get cache statistics - */ -export async function getCacheStats(): Promise { - const response = await fetch(`${config.baseUrl}/cache/stats`); - - if (!response.ok) { - throw new Error(`Failed to fetch cache stats: ${response.status}`); - } - - return response.json(); -} - -/** - * Invalidate cache entries - */ -export async function invalidateSEOCache( - pattern: string = '*' -): Promise<{ invalidated_count: number }> { - const response = await fetch( - `${config.baseUrl}/cache?pattern=${encodeURIComponent(pattern)}`, - { method: 'DELETE' } - ); - - if (!response.ok) { - throw new Error(`Failed to invalidate cache: ${response.status}`); - } - - return response.json(); -} - -/** - * Create cache key for SEO data - */ -export function makeSEOCacheKey(pageType: string, locale: string): string { - return `seo:${locale}:${pageType}`; -} diff --git a/@packages/@infrastructure/seo-client/src/hooks.ts b/@packages/@infrastructure/seo-client/src/hooks.ts deleted file mode 100644 index a9c37f878..000000000 --- a/@packages/@infrastructure/seo-client/src/hooks.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * React hooks for SEO service - */ - -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { - generateSEO, - getSEOTemplates, - getCacheStats, - invalidateSEOCache, - makeSEOCacheKey, -} from './client'; -import type { SEOMetadata, SEOGenerateResponse, SEOTemplate, CacheStats } from './types'; - -/** - * Query keys for SEO data - */ -export const seoQueryKeys = { - all: ['seo'] as const, - metadata: (pageType: string, locale: string) => - [...seoQueryKeys.all, 'metadata', pageType, locale] as const, - templates: () => [...seoQueryKeys.all, 'templates'] as const, - cacheStats: () => [...seoQueryKeys.all, 'cache-stats'] as const, -}; - -/** - * Hook to get SEO metadata for a page - * - * @param pageType - Type of page (landing, profile, etc.) - * @param locale - Target locale (default: en) - * @returns SEO metadata for the page - */ -export function useSEO( - pageType: string, - locale: string = 'en' -): { - metadata: SEOMetadata | undefined; - isLoading: boolean; - error: Error | null; - refetch: () => void; -} { - const { data, isLoading, error, refetch } = useQuery({ - queryKey: seoQueryKeys.metadata(pageType, locale), - queryFn: async () => { - const result = await generateSEO(pageType, locale); - return result.metadata; - }, - staleTime: 1000 * 60 * 5, // 5 minutes - }); - - return { - metadata: data, - isLoading, - error: error as Error | null, - refetch, - }; -} - -/** - * Hook to get SEO defaults - */ -export function useSEODefaults(): { - siteName: string; - twitterHandle: string; - ogImage: string; -} { - return { - siteName: 'Lilith Platform', - twitterHandle: '@lilithplatform', - ogImage: '/og-default.png', - }; -} - -/** - * Hook to preload SEO for multiple pages - */ -export function useSEOPreload( - pages: Array<{ pageType: string; locale: string }> -): void { - const queryClient = useQueryClient(); - - // Prefetch all pages on mount - pages.forEach(({ pageType, locale }) => { - queryClient.prefetchQuery({ - queryKey: seoQueryKeys.metadata(pageType, locale), - queryFn: () => generateSEO(pageType, locale).then((r) => r.metadata), - }); - }); -} - -/** - * Hook to get SEO templates - */ -export function useSEOTemplates(): { - templates: SEOTemplate[]; - isLoading: boolean; - error: Error | null; -} { - const { data, isLoading, error } = useQuery({ - queryKey: seoQueryKeys.templates(), - queryFn: getSEOTemplates, - staleTime: 1000 * 60 * 30, // 30 minutes - }); - - return { - templates: data?.templates ?? [], - isLoading, - error: error as Error | null, - }; -} - -/** - * Hook to get cache statistics - */ -export function useSEOCacheStats(): { - stats: CacheStats | undefined; - isLoading: boolean; - refetch: () => void; -} { - const { data, isLoading, refetch } = useQuery({ - queryKey: seoQueryKeys.cacheStats(), - queryFn: getCacheStats, - refetchInterval: 30000, // 30 seconds - }); - - return { - stats: data, - isLoading, - refetch, - }; -} - -/** - * Hook to generate SEO on demand - */ -export function useSEOGenerate(): { - generate: (pageType: string, locale?: string) => Promise; - isGenerating: boolean; - error: Error | null; -} { - const queryClient = useQueryClient(); - - const mutation = useMutation({ - mutationFn: ({ pageType, locale }: { pageType: string; locale?: string }) => - generateSEO(pageType, locale), - onSuccess: (data, { pageType, locale }) => { - queryClient.setQueryData( - seoQueryKeys.metadata(pageType, locale ?? 'en'), - data.metadata - ); - }, - }); - - return { - generate: (pageType: string, locale?: string) => - mutation.mutateAsync({ pageType, locale }), - isGenerating: mutation.isPending, - error: mutation.error as Error | null, - }; -} - -/** - * Hook to invalidate SEO cache - */ -export function useSEOCacheInvalidate(): { - invalidate: (pattern?: string) => Promise; - isInvalidating: boolean; -} { - const queryClient = useQueryClient(); - - const mutation = useMutation({ - mutationFn: (pattern?: string) => invalidateSEOCache(pattern), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: seoQueryKeys.all }); - }, - }); - - return { - invalidate: async (pattern?: string) => { - const result = await mutation.mutateAsync(pattern); - return result.invalidated_count; - }, - isInvalidating: mutation.isPending, - }; -} diff --git a/@packages/@infrastructure/seo-client/src/index.ts b/@packages/@infrastructure/seo-client/src/index.ts deleted file mode 100644 index 998c63e00..000000000 --- a/@packages/@infrastructure/seo-client/src/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @lilith/seo-client - * - * SEO service client for Lilith Platform. - * Provides API client and React hooks for SEO generation. - */ - -// Types -export type { - SEOMetadata, - SEOGenerateRequest, - SEOGenerateResponse, - SEOTemplate, - CacheStats, - SEOClientConfig, -} from './types'; - -// Client functions -export { - configureSEOClient, - getSEOClientConfig, - generateSEO, - generateSEOBatch, - getSEOTemplates, - getCacheStats, - invalidateSEOCache, - makeSEOCacheKey, -} from './client'; - -// React hooks -export { - seoQueryKeys, - useSEO, - useSEODefaults, - useSEOPreload, - useSEOTemplates, - useSEOCacheStats, - useSEOGenerate, - useSEOCacheInvalidate, -} from './hooks'; diff --git a/@packages/@infrastructure/seo-client/src/types.ts b/@packages/@infrastructure/seo-client/src/types.ts deleted file mode 100644 index 9d13a2c94..000000000 --- a/@packages/@infrastructure/seo-client/src/types.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * SEO client types - */ - -export interface SEOMetadata { - title: string; - description: string; - keywords: string; - og_title?: string | null; - og_description?: string | null; - og_image?: string | null; - og_type: string; - canonical_url?: string | null; - robots: string; - locale: string; -} - -export interface SEOGenerateRequest { - page_type: string; - locale?: string; - context?: Record; - template_id?: string; - validate?: boolean; -} - -export interface SEOGenerateResponse { - metadata: SEOMetadata; - cached: boolean; - validation_passed?: boolean | null; - generation_time_ms?: number | null; -} - -export interface SEOTemplate { - id: string; - page_type: string; - title_template: string; - description_template: string; - keywords_template: string; - variables: string[]; -} - -export interface CacheStats { - hits: number; - misses: number; - hit_rate: number; - total_entries: number; - memory_usage_mb?: number | null; -} - -export interface SEOClientConfig { - baseUrl: string; - defaultLocale?: string; - timeout?: number; -} diff --git a/@packages/@infrastructure/seo-client/tsconfig.json b/@packages/@infrastructure/seo-client/tsconfig.json deleted file mode 100644 index b54386af1..000000000 --- a/@packages/@infrastructure/seo-client/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/features/seo/frontend-admin/package.json b/features/seo/frontend-admin/package.json new file mode 100644 index 000000000..f1ef46172 --- /dev/null +++ b/features/seo/frontend-admin/package.json @@ -0,0 +1,23 @@ +{ + "name": "@lilith/seo-admin", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@lilith/seo-shared": "workspace:*", + "@tanstack/react-query": "^5.75.7", + "react": "^19.1.0", + "react-router-dom": "^7.1.1" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } +} diff --git a/features/seo/frontend-admin/src/SEOPage.tsx b/features/seo/frontend-admin/src/SEOPage.tsx new file mode 100644 index 000000000..c96a0ecd9 --- /dev/null +++ b/features/seo/frontend-admin/src/SEOPage.tsx @@ -0,0 +1,480 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; +import type { + SEOMetadata, + DomainSEOConfig, + SEOGenerateResponse, +} from '@lilith/seo-shared'; + +const SEO_SERVICE_URL = '/api/seo'; + +async function fetchDomains(): Promise<{ domains: string[] }> { + const res = await fetch(`${SEO_SERVICE_URL}/config/domains`); + if (!res.ok) throw new Error('Failed to fetch domains'); + return res.json(); +} + +async function fetchDomainConfig(domain: string): Promise { + const res = await fetch(`${SEO_SERVICE_URL}/config/domain/${encodeURIComponent(domain)}`); + if (!res.ok) throw new Error('Failed to fetch domain config'); + return res.json(); +} + +async function createDomainConfig(config: DomainSEOConfig): Promise { + const res = await fetch(`${SEO_SERVICE_URL}/config/domain`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + if (!res.ok) throw new Error('Failed to create domain config'); + return res.json(); +} + +async function updateDomainConfig(domain: string, updates: Partial): Promise { + const res = await fetch(`${SEO_SERVICE_URL}/config/domain/${encodeURIComponent(domain)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + if (!res.ok) throw new Error('Failed to update domain config'); + return res.json(); +} + +async function deleteDomainConfig(domain: string): Promise { + const res = await fetch(`${SEO_SERVICE_URL}/config/domain/${encodeURIComponent(domain)}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error('Failed to delete domain config'); +} + +async function generateSEO( + domain: string, + path: string, + pageType: string, + locale: string, + validateTruth: boolean = true, +): Promise { + const res = await fetch(`${SEO_SERVICE_URL}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domain, path, pageType, locale, validateTruth }), + }); + if (!res.ok) throw new Error('Failed to generate SEO'); + return res.json(); +} + +export function SEOPage() { + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedDomain = searchParams.get('domain') || ''; + + const [showNewDomainForm, setShowNewDomainForm] = useState(false); + const [newDomain, setNewDomain] = useState(''); + const [editingConfig, setEditingConfig] = useState>({}); + + // Preview state + const [previewPath, setPreviewPath] = useState('/'); + const [previewLocale, setPreviewLocale] = useState('en'); + const [previewResult, setPreviewResult] = useState(null); + + const { data: domains, isLoading: domainsLoading } = useQuery({ + queryKey: ['seo-domains'], + queryFn: fetchDomains, + }); + + const { data: domainConfig, isLoading: configLoading, error: configError } = useQuery({ + queryKey: ['seo-domain-config', selectedDomain], + queryFn: () => fetchDomainConfig(selectedDomain), + enabled: !!selectedDomain, + retry: false, + }); + + useEffect(() => { + if (domainConfig) { + setEditingConfig(domainConfig); + } + }, [domainConfig]); + + const createMutation = useMutation({ + mutationFn: createDomainConfig, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seo-domains'] }); + setShowNewDomainForm(false); + setNewDomain(''); + }, + }); + + const updateMutation = useMutation({ + mutationFn: (updates: Partial) => updateDomainConfig(selectedDomain, updates), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seo-domain-config', selectedDomain] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: () => deleteDomainConfig(selectedDomain), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seo-domains'] }); + setSearchParams({}); + }, + }); + + const generateMutation = useMutation({ + mutationFn: () => generateSEO( + selectedDomain, + previewPath, + domainConfig?.pages[previewPath]?.pageType || 'page', + previewLocale, + true, + ), + onSuccess: (data) => { + setPreviewResult(data); + }, + }); + + const selectDomain = (domain: string) => { + setSearchParams({ domain }); + setPreviewResult(null); + }; + + const handleCreateDomain = () => { + if (!newDomain) return; + createMutation.mutate({ + domain: newDomain, + defaultLocale: 'en', + supportedLocales: ['en'], + siteName: newDomain, + pages: {}, + autoGenerate: true, + updatedAt: new Date().toISOString(), + }); + }; + + const handleSaveConfig = () => { + updateMutation.mutate(editingConfig); + }; + + return ( +
+
+
+

SEO Configuration

+

+ Multi-tenant SEO management - Configure SEO per domain +

+
+
+ Service Online + Port 41230 +
+
+ +
+ {/* Domain List */} +
+
+
+

Domains

+ +
+ + {showNewDomainForm && ( +
+ setNewDomain(e.target.value)} + placeholder="e.g., www.atlilith.com" + className="input w-full mb-2" + /> +
+ + +
+
+ )} + + {domainsLoading ? ( +
Loading...
+ ) : ( +
    + {domains?.domains.map((domain) => ( +
  • selectDomain(domain)} + className={`px-3 py-2 rounded cursor-pointer ${ + selectedDomain === domain + ? 'bg-brand-600 text-white' + : 'hover:bg-gray-800' + }`} + > + {domain} +
  • + ))} + {domains?.domains.length === 0 && ( +
  • No domains configured
  • + )} +
+ )} +
+
+ + {/* Domain Configuration */} +
+ {!selectedDomain ? ( +
+ Select a domain from the list or create a new one +
+ ) : configLoading ? ( +
+ Loading configuration... +
+ ) : configError ? ( +
+
+ No configuration found for {selectedDomain} +
+ +
+ ) : ( +
+ {/* Domain Settings */} +
+

Domain Settings

+
+
+ + setEditingConfig({ ...editingConfig, siteName: e.target.value })} + className="input w-full" + /> +
+
+ + setEditingConfig({ ...editingConfig, defaultLocale: e.target.value })} + className="input w-full" + /> +
+
+ + setEditingConfig({ + ...editingConfig, + supportedLocales: e.target.value.split(',').map(s => s.trim()), + })} + placeholder="en, es, fr, de" + className="input w-full" + /> +
+
+ + setEditingConfig({ ...editingConfig, twitterHandle: e.target.value })} + placeholder="@handle" + className="input w-full" + /> +
+
+ + setEditingConfig({ ...editingConfig, defaultOgImage: e.target.value })} + className="input w-full" + /> +
+
+ setEditingConfig({ ...editingConfig, autoGenerate: e.target.checked })} + /> + +
+
+
+ + +
+
+ + {/* Page Configurations */} +
+

Page Configurations

+ {Object.keys(domainConfig?.pages || {}).length === 0 ? ( +

+ No page-specific SEO configured. Use the SEO app at {selectedDomain}/_/ to configure pages. +

+ ) : ( +
+ {Object.entries(domainConfig?.pages || {}).map(([path, config]) => ( +
+
+ {path} + {config.pageType} +
+
+ {Object.keys(config.overrides || {}).length} locale(s) +
+
+ ))} +
+ )} +
+ + {/* SEO Preview */} +
+

Preview SEO

+
+
+ + setPreviewPath(e.target.value)} + className="input w-full" + /> +
+
+ + +
+
+ +
+
+ + {previewResult && ( +
+
+
+ Source + + {previewResult.source} + +
+
+
+ Title: +

{previewResult.metadata.title}

+
+
+ Description: +

{previewResult.metadata.description}

+
+
+ Keywords: +

{previewResult.metadata.keywords?.join(', ') || 'None'}

+
+
+
+ + {previewResult.truthValidation && ( +
+

+ Truth Validation: {previewResult.truthValidation.valid ? 'Passed' : 'Issues Found'} +

+ {previewResult.truthValidation.issues.length > 0 ? ( +
    + {previewResult.truthValidation.issues.map((issue, i) => ( +
  • + [{issue.severity}] {issue.message} +
  • + ))} +
+ ) : ( +

All content validates against platform facts

+ )} +
+ )} +
+ )} +
+ + {/* Link to SEO App */} +
+

SEO Management App

+

+ For detailed page-by-page SEO configuration, use the dedicated SEO app: +

+ + Open SEO App for {selectedDomain} + +
+
+ )} +
+
+
+ ); +} diff --git a/features/seo/frontend-admin/src/index.ts b/features/seo/frontend-admin/src/index.ts new file mode 100644 index 000000000..af7933b34 --- /dev/null +++ b/features/seo/frontend-admin/src/index.ts @@ -0,0 +1 @@ +export { SEOPage } from './SEOPage'; diff --git a/features/seo/frontend/index.html b/features/seo/frontend/index.html new file mode 100644 index 000000000..c352585ca --- /dev/null +++ b/features/seo/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + SEO Management - Lilith Platform + + +
+ + + diff --git a/features/seo/frontend/package.json b/features/seo/frontend/package.json new file mode 100644 index 000000000..a477f590c --- /dev/null +++ b/features/seo/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "@lilith/seo-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint src --ext ts,tsx", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@lilith/seo-shared": "workspace:*", + "@transquinnftw/ui-theme": "^1.0.0", + "@tanstack/react-query": "^5.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "typescript": "^5.6.0", + "vite": "^6.0.0", + "vitest": "^2.0.0" + } +} diff --git a/features/seo/frontend/src/App.tsx b/features/seo/frontend/src/App.tsx new file mode 100644 index 000000000..5873609f5 --- /dev/null +++ b/features/seo/frontend/src/App.tsx @@ -0,0 +1,29 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { useMemo } from 'react'; + +import { Layout } from './components/Layout'; +import { DomainConfigPage } from './pages/DomainConfigPage'; +import { PageConfigPage } from './pages/PageConfigPage'; +import { PreviewPage } from './pages/PreviewPage'; + +function App() { + const domain = useMemo(() => { + const params = new URLSearchParams(window.location.search); + return params.get('domain') || window.location.hostname; + }, []); + + return ( + + + }> + } /> + } /> + } /> + } /> + + + + ); +} + +export default App; diff --git a/features/seo/frontend/src/api/seo.ts b/features/seo/frontend/src/api/seo.ts new file mode 100644 index 000000000..d6e27e31c --- /dev/null +++ b/features/seo/frontend/src/api/seo.ts @@ -0,0 +1,80 @@ +import type { + DomainSEOConfig, + PageSEOConfig, + SEOGenerateRequest, + SEOGenerateResponse, +} from '@lilith/seo-shared'; + +const API_BASE = '/api/seo'; + +async function fetchApi(path: string, options?: RequestInit): Promise { + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +export const seoApi = { + listDomains: () => + fetchApi<{ domains: string[] }>('/config/domains'), + + getDomainConfig: (domain: string) => + fetchApi(`/config/domain/${encodeURIComponent(domain)}`), + + createDomainConfig: (config: DomainSEOConfig) => + fetchApi('/config/domain', { + method: 'POST', + body: JSON.stringify(config), + }), + + updateDomainConfig: (domain: string, updates: Partial) => + fetchApi(`/config/domain/${encodeURIComponent(domain)}`, { + method: 'PUT', + body: JSON.stringify(updates), + }), + + deleteDomainConfig: (domain: string) => + fetchApi<{ success: boolean }>(`/config/domain/${encodeURIComponent(domain)}`, { + method: 'DELETE', + }), + + listPages: (domain: string) => + fetchApi<{ pages: string[] }>(`/config/domain/${encodeURIComponent(domain)}/pages`), + + getPageConfig: (domain: string, path: string) => + fetchApi( + `/config/domain/${encodeURIComponent(domain)}/page?path=${encodeURIComponent(path)}` + ), + + setPageConfig: (domain: string, path: string, config: PageSEOConfig) => + fetchApi( + `/config/domain/${encodeURIComponent(domain)}/page?path=${encodeURIComponent(path)}`, + { + method: 'PUT', + body: JSON.stringify(config), + } + ), + + deletePageConfig: (domain: string, path: string) => + fetchApi<{ success: boolean }>( + `/config/domain/${encodeURIComponent(domain)}/page?path=${encodeURIComponent(path)}`, + { + method: 'DELETE', + } + ), + + generateSEO: (request: SEOGenerateRequest) => + fetchApi('/generate', { + method: 'POST', + body: JSON.stringify(request), + }), +}; diff --git a/features/seo/frontend/src/components/Layout.tsx b/features/seo/frontend/src/components/Layout.tsx new file mode 100644 index 000000000..572cc5cc8 --- /dev/null +++ b/features/seo/frontend/src/components/Layout.tsx @@ -0,0 +1,56 @@ +import { Outlet, NavLink } from 'react-router-dom'; + +interface LayoutProps { + domain: string; +} + +export function Layout({ domain }: LayoutProps) { + return ( +
+
+
+

SEO Manager

+ {domain} +
+ +
+
+ +
+
+ ); +} diff --git a/features/seo/frontend/src/main.tsx b/features/seo/frontend/src/main.tsx new file mode 100644 index 000000000..d448fcd41 --- /dev/null +++ b/features/seo/frontend/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + retry: 1, + }, + }, +}); + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/features/seo/frontend/src/pages/DomainConfigPage.tsx b/features/seo/frontend/src/pages/DomainConfigPage.tsx new file mode 100644 index 000000000..2cbe0a151 --- /dev/null +++ b/features/seo/frontend/src/pages/DomainConfigPage.tsx @@ -0,0 +1,160 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import type { DomainSEOConfig } from '@lilith/seo-shared'; +import { seoApi } from '../api/seo'; + +interface DomainConfigPageProps { + domain: string; +} + +export function DomainConfigPage({ domain }: DomainConfigPageProps) { + const queryClient = useQueryClient(); + + const { data: config, isLoading, error } = useQuery({ + queryKey: ['domainConfig', domain], + queryFn: () => seoApi.getDomainConfig(domain), + retry: false, + }); + + const [formData, setFormData] = useState>({ + domain, + defaultLocale: 'en', + supportedLocales: ['en'], + siteName: '', + autoGenerate: true, + }); + + const createMutation = useMutation({ + mutationFn: (data: DomainSEOConfig) => seoApi.createDomainConfig(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['domainConfig', domain] }); + }, + }); + + const updateMutation = useMutation({ + mutationFn: (data: Partial) => seoApi.updateDomainConfig(domain, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['domainConfig', domain] }); + }, + }); + + if (isLoading) { + return
Loading configuration...
; + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (config) { + updateMutation.mutate(formData); + } else { + createMutation.mutate({ + ...formData, + domain, + pages: {}, + updatedAt: new Date().toISOString(), + } as DomainSEOConfig); + } + }; + + const currentData = config || formData; + + return ( +
+

Domain Configuration

+

+ Configure SEO settings for {domain} +

+ + {error && !config && ( +
+ No configuration exists for this domain. Create one below. +
+ )} + +
+ + + + + + + + + + + + + +
+ + {config && ( +
+

+ Last updated: {new Date(config.updatedAt).toLocaleString()} +

+
+ )} +
+ ); +} diff --git a/features/seo/frontend/src/pages/PageConfigPage.tsx b/features/seo/frontend/src/pages/PageConfigPage.tsx new file mode 100644 index 000000000..351a5f39c --- /dev/null +++ b/features/seo/frontend/src/pages/PageConfigPage.tsx @@ -0,0 +1,200 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import type { PageSEOConfig, SEOMetadata } from '@lilith/seo-shared'; +import { seoApi } from '../api/seo'; + +interface PageConfigPageProps { + domain: string; +} + +export function PageConfigPage({ domain }: PageConfigPageProps) { + const queryClient = useQueryClient(); + const [selectedPage, setSelectedPage] = useState(null); + const [newPagePath, setNewPagePath] = useState(''); + + const { data: pages, isLoading } = useQuery({ + queryKey: ['pages', domain], + queryFn: () => seoApi.listPages(domain), + }); + + const { data: pageConfig } = useQuery({ + queryKey: ['pageConfig', domain, selectedPage], + queryFn: () => selectedPage ? seoApi.getPageConfig(domain, selectedPage) : null, + enabled: !!selectedPage, + }); + + const saveMutation = useMutation({ + mutationFn: ({ path, config }: { path: string; config: PageSEOConfig }) => + seoApi.setPageConfig(domain, path, config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pages', domain] }); + queryClient.invalidateQueries({ queryKey: ['pageConfig', domain] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (path: string) => seoApi.deletePageConfig(domain, path), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pages', domain] }); + setSelectedPage(null); + }, + }); + + const [editForm, setEditForm] = useState>({ + path: '', + pageType: 'page', + overrides: {}, + }); + + const [localeOverride, setLocaleOverride] = useState('en'); + const [metadataForm, setMetadataForm] = useState>({}); + + if (isLoading) { + return
Loading pages...
; + } + + const handleAddPage = () => { + if (!newPagePath) return; + const path = newPagePath.startsWith('/') ? newPagePath : `/${newPagePath}`; + setSelectedPage(path); + setEditForm({ path, pageType: 'page', overrides: {} }); + setNewPagePath(''); + }; + + const handleSave = () => { + if (!selectedPage) return; + + const config: PageSEOConfig = { + path: selectedPage, + pageType: editForm.pageType || 'page', + overrides: { + ...pageConfig?.overrides, + [localeOverride]: metadataForm, + }, + variables: editForm.variables, + }; + + saveMutation.mutate({ path: selectedPage, config }); + }; + + return ( +
+
+

Pages

+
+ setNewPagePath(e.target.value)} + placeholder="/new-page" + style={{ flex: 1, padding: '0.5rem' }} + /> + +
+
    + {pages?.pages.map((path) => ( +
  • setSelectedPage(path)} + style={{ + padding: '0.5rem', + cursor: 'pointer', + background: selectedPage === path ? '#333' : 'transparent', + borderRadius: '4px', + }} + > + {path} +
  • + ))} +
+
+ +
+ {selectedPage ? ( + <> +

Configure: {selectedPage}

+ + + +

SEO Overrides

+ + + + +