From d5fcb0278443d91bfca44d59ec99bfdb814be0b1 Mon Sep 17 00:00:00 2001 From: luisfponce Date: Wed, 15 Jul 2026 21:58:18 -0600 Subject: [PATCH] feat(prompts): add titled prompts with localized signup seeds Add required prompt titles across backend and frontend prompt flows. Return authenticated signup sessions with preferred language support. Seed new user accounts with localized creator prompts. Update landing page messaging and fix long prompt detail dialog overflow. --- .../prompts/prompt-detail-dialog.tsx | 6 +- .../src/components/prompts/prompt-form.tsx | 12 +- .../src/components/prompts/prompt-list.tsx | 3 +- frontend/src/features/auth/auth-service.ts | 16 ++ frontend/src/features/auth/auth-store.tsx | 26 +- frontend/src/features/auth/auth-types.ts | 7 + .../src/features/prompts/prompts-types.ts | 2 + frontend/src/i18n/locales/en.ts | 42 ++- frontend/src/i18n/locales/es.ts | 42 ++- frontend/src/lib/validation/auth-schemas.ts | 1 + frontend/src/lib/validation/prompt-schemas.ts | 1 + .../src/pages/admin/admin-prompts-page.tsx | 4 +- frontend/src/pages/landing/landing-page.tsx | 38 +++ frontend/src/pages/register/register-page.tsx | 28 +- frontend/src/styles/base.css | 47 +++- webapi/api/endpoints/v1/auths.py | 36 ++- webapi/api/endpoints/v1/prompts.py | 2 + webapi/core/creator_prompts.py | 249 ++++++++++++++++++ webapi/models/prompts.py | 1 + webapi/models/user.py | 6 + webapi/schemas/prompt_schema.py | 3 + webapi/schemas/user_schema.py | 6 +- webapi/tests/conftest.py | 2 + webapi/tests/functional/test_auth_routes.py | 57 +++- .../tests/functional/test_prompts_routes.py | 22 ++ webapi/tests/nonfunctional/test_ci.py | 9 +- 26 files changed, 640 insertions(+), 28 deletions(-) create mode 100644 webapi/core/creator_prompts.py diff --git a/frontend/src/components/prompts/prompt-detail-dialog.tsx b/frontend/src/components/prompts/prompt-detail-dialog.tsx index ddf0b5c..f0f2a2f 100644 --- a/frontend/src/components/prompts/prompt-detail-dialog.tsx +++ b/frontend/src/components/prompts/prompt-detail-dialog.tsx @@ -38,9 +38,13 @@ export function PromptDetailDialog({ aria-labelledby="prompt-detail-title" onMouseDown={(event) => event.stopPropagation()} > -

{t('prompts.detail.title')}

+

{prompt.title}

+
+ {t('prompts.detail.titleLabel')} + {prompt.title} +
{t('prompts.detail.model')} {prompt.model_name} diff --git a/frontend/src/components/prompts/prompt-form.tsx b/frontend/src/components/prompts/prompt-form.tsx index 1d47f9c..38021f5 100644 --- a/frontend/src/components/prompts/prompt-form.tsx +++ b/frontend/src/components/prompts/prompt-form.tsx @@ -5,6 +5,7 @@ import type { PromptInput, PromptRecord } from '../../features/prompts/prompts-t import { promptSchema } from '../../lib/validation/prompt-schemas' import { Button } from '../ui/button' import { ComboboxInput } from '../ui/combobox-input' +import { Input } from '../ui/input' import { RatingInput } from '../ui/rating-input' import { Select } from '../ui/select' import { Textarea } from '../ui/textarea' @@ -32,6 +33,7 @@ export function PromptForm({ }: PromptFormProps) { const { t } = useTranslation() const [form, setForm] = useState({ + title: initialValue?.title ?? '', model_name: initialValue?.model_name ?? '', prompt_text: initialValue?.prompt_text ?? '', category: initialValue?.category ?? '', @@ -74,12 +76,20 @@ export function PromptForm({ setFieldErrors({}) await onSubmit(parsed.data) if (!isEdit) { - setForm({ model_name: '', prompt_text: '', category: '', rate: 3 }) + setForm({ title: '', model_name: '', prompt_text: '', category: '', rate: 3 }) } } return (
+ handleChange('title', e.target.value)} + error={fieldErrors.title} + placeholder={t('prompts.form.titlePlaceholder')} + maxLength={120} + /> setSelectedPrompt(prompt)} > - {prompt.model_name} + {prompt.title} {summary} + {prompt.model_name} {prompt.category} {t('common.rating', { value: prompt.rate })} diff --git a/frontend/src/features/auth/auth-service.ts b/frontend/src/features/auth/auth-service.ts index 48bfa35..fdb0a45 100644 --- a/frontend/src/features/auth/auth-service.ts +++ b/frontend/src/features/auth/auth-service.ts @@ -17,6 +17,7 @@ export type SessionResolved = { username: string userId: number role: UserRole + preferredLanguage: UserRecord['preferred_language'] } export async function loginAndResolveUserId( @@ -34,6 +35,7 @@ export async function loginAndResolveUserId( username: currentUser.username, userId: currentUser.id, role: currentUser.role, + preferredLanguage: currentUser.preferred_language, } } @@ -44,6 +46,20 @@ export function signup(input: RegisterInput): Promise { }) } +export async function signupAndResolveSession( + input: RegisterInput, +): Promise { + const response = await signup(input) + + return { + token: response.access_token, + username: response.user.username, + userId: response.user.id, + role: response.user.role, + preferredLanguage: response.user.preferred_language, + } +} + export function getCurrentUser(token: string): Promise { return apiRequest('/auth/profile', { method: 'GET', diff --git a/frontend/src/features/auth/auth-store.tsx b/frontend/src/features/auth/auth-store.tsx index 3002416..059db4d 100644 --- a/frontend/src/features/auth/auth-store.tsx +++ b/frontend/src/features/auth/auth-store.tsx @@ -8,14 +8,15 @@ import { useState, type ReactNode, } from 'react' -import { getCurrentUser, loginAndResolveUserId } from './auth-service' -import type { UserRole } from './auth-types' +import { getCurrentUser, loginAndResolveUserId, signupAndResolveSession } from './auth-service' +import type { PreferredLanguage, RegisterInput, UserRole } from './auth-types' type SessionState = { token: string | null username: string | null userId: number | null role: UserRole | null + preferredLanguage: PreferredLanguage | null } type AuthContextValue = { @@ -23,6 +24,7 @@ type AuthContextValue = { isAuthenticated: boolean isReady: boolean login: (username: string, password: string) => Promise + signup: (input: RegisterInput) => Promise logout: () => void } @@ -42,6 +44,7 @@ export function AuthProvider({ children }: AuthProviderProps) { username: null, userId: null, role: null, + preferredLanguage: null, }) const [isReady, setIsReady] = useState(() => !initialToken) @@ -57,6 +60,7 @@ export function AuthProvider({ children }: AuthProviderProps) { username: user.username, userId: user.id, role: user.role, + preferredLanguage: user.preferred_language, }) }) .catch(() => { @@ -79,12 +83,25 @@ export function AuthProvider({ children }: AuthProviderProps) { username: resolved.username, userId: resolved.userId, role: resolved.role, + preferredLanguage: resolved.preferredLanguage, + }) + }, []) + + const signup = useCallback(async (input: RegisterInput) => { + const resolved = await signupAndResolveSession(input) + sessionStorage.setItem(SESSION_TOKEN_KEY, resolved.token) + setSession({ + token: resolved.token, + username: resolved.username, + userId: resolved.userId, + role: resolved.role, + preferredLanguage: resolved.preferredLanguage, }) }, []) const logout = useCallback(() => { sessionStorage.removeItem(SESSION_TOKEN_KEY) - setSession({ token: null, username: null, userId: null, role: null }) + setSession({ token: null, username: null, userId: null, role: null, preferredLanguage: null }) }, []) const value = useMemo( @@ -93,9 +110,10 @@ export function AuthProvider({ children }: AuthProviderProps) { isAuthenticated: Boolean(session.token), isReady, login, + signup, logout, }), - [isReady, login, logout, session], + [isReady, login, logout, session, signup], ) return {children} diff --git a/frontend/src/features/auth/auth-types.ts b/frontend/src/features/auth/auth-types.ts index a64fd2d..930bdc8 100644 --- a/frontend/src/features/auth/auth-types.ts +++ b/frontend/src/features/auth/auth-types.ts @@ -8,12 +8,15 @@ export type LoginResponse = { token_type: string } +export type PreferredLanguage = 'es' | 'en' + export type RegisterInput = { username: string password: string name: string last_name: string email: string + preferred_language: PreferredLanguage } export type UserRole = 'user' | 'admin' | 'god' @@ -24,11 +27,15 @@ export type UserRecord = { name: string last_name: string email: string + preferred_language: PreferredLanguage role: UserRole } export type SignupResponse = { message: string + access_token: string + token_type: string + user: UserRecord } export type RecoveryGenerateInput = { diff --git a/frontend/src/features/prompts/prompts-types.ts b/frontend/src/features/prompts/prompts-types.ts index cc35354..57477a3 100644 --- a/frontend/src/features/prompts/prompts-types.ts +++ b/frontend/src/features/prompts/prompts-types.ts @@ -1,6 +1,7 @@ export type PromptRecord = { id: number user_id: number + title: string model_name: string prompt_text: string category: string @@ -8,6 +9,7 @@ export type PromptRecord = { } export type PromptInput = { + title: string model_name: string prompt_text: string category: string diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 5dde42e..5960bc2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1,6 +1,6 @@ export const en = { app: { - name: 'Prompt Catalog', + name: 'iPrompt', tagline: 'Personal library for prompts that work', }, language: { @@ -40,6 +40,39 @@ export const en = { previewEyebrow: 'Catalog preview', previewTitle: 'A warmer home for your reusable AI work', previewCopy: 'Preview how saved prompts can be organized with ratings, models, categories, and quick context before you commit them to your own library.', + creatorOffers: { + eyebrow: 'Free creator starter pack', + title: '3 creator-built prompts included with your free account.', + copy: 'Sign up and iPrompt seeds your catalog with three complete, editable prompts in your preferred language.', + included: 'Create a free account to unlock the full prompt bodies in your private catalog.', + cta: 'Create free account', + cards: [ + { + title: 'Appliance Value Expert', + category: 'Finance & Investing', + model: 'GPT', + rating: '5/5', + outcome: 'Compare appliance options in Mexico by price, warranty, ownership cost, reliability, and practical performance.', + locked: 'Full quote workflow unlocks after signup.', + }, + { + title: 'Personal Meal Planning Expert', + category: 'Personal Development', + model: 'GPT', + rating: '5/5', + outcome: 'Plan realistic 30-day meals around budget, preferences, prep time, and safety-aware wellness context.', + locked: 'Educational meal-planning prompt included after signup; not a replacement for clinical care.', + }, + { + title: 'Influencer Content Planner', + category: 'Marketing & Sales', + model: 'GPT', + rating: '5/5', + outcome: 'Build an editorial calendar with hooks, scripts, captions, repurposing ideas, and weekly metrics.', + locked: 'Full two-step planning prompt unlocks in your catalog.', + }, + ], + }, benefitsLabel: 'Prompt library benefits', benefits: [ { @@ -103,6 +136,8 @@ export const en = { firstName: 'First name', lastName: 'Last name', email: 'Email', + preferredLanguage: 'Prompt language', + preferredLanguageHelp: 'Your starter prompts will be saved in this language. You can still switch the interface separately.', creating: 'Creating...', alreadyHaveAccount: 'Already have an account?', invalidCredentials: 'Invalid credentials', @@ -153,6 +188,7 @@ export const en = { emptyRating: 'No ratings yet', }, form: { + title: 'Title', model: 'Model', prompt: 'Prompt text', category: 'Category', @@ -161,10 +197,12 @@ export const en = { update: 'Update prompt', knownModel: 'Select a known model from the list.', invalid: 'Invalid form data', + titlePlaceholder: 'Name this prompt so it is easy to find later.', promptPlaceholder: 'Paste the prompt that worked, including any constraints or context you want to reuse.', }, detail: { title: 'Prompt details', + titleLabel: 'Title', model: 'Model', prompt: 'Prompt', category: 'Category', @@ -190,7 +228,7 @@ export const en = { }, editTitle: 'Edit prompt as god', allPrompts: 'All prompts', - promptMeta: 'User {{userId}} · {{category}} · Rating {{rating}}/5', + promptMeta: 'User {{userId}} · {{model}} · {{category}} · Rating {{rating}}/5', noPrompts: 'No prompts found.', deleteTitle: 'Delete this prompt?', deleteDescription: 'This removes the selected prompt from the catalog for every admin view.', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 4980d44..e0356af 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -1,6 +1,6 @@ export const es = { app: { - name: 'Catálogo de Prompts', + name: 'iPrompt', tagline: 'Biblioteca personal para prompts que funcionan', }, language: { @@ -40,6 +40,39 @@ export const es = { previewEyebrow: 'Vista previa del catálogo', previewTitle: 'Un espacio más claro para tu trabajo reutilizable con IA', previewCopy: 'Mira cómo tus prompts guardados pueden organizarse con calificaciones, modelos, categorías y contexto útil antes de llevarlos a tu propia biblioteca.', + creatorOffers: { + eyebrow: 'Pack inicial gratis para creadores', + title: '3 prompts creados para creadores incluidos con tu cuenta gratis.', + copy: 'Regístrate e iPrompt agrega a tu catálogo tres prompts completos y editables en tu idioma preferido.', + included: 'Crea una cuenta gratis para desbloquear los prompts completos en tu catálogo privado.', + cta: 'Crear cuenta gratis', + cards: [ + { + title: 'Experto cotizador de electrodomesticos', + category: 'Finanzas e inversión', + model: 'GPT', + rating: '5/5', + outcome: 'Compara opciones de electrodomésticos en México por precio, garantía, costo de propiedad, confiabilidad y desempeño práctico.', + locked: 'El flujo completo de cotización se desbloquea después del registro.', + }, + { + title: 'Experto nutriologo personal', + category: 'Desarrollo personal', + model: 'GPT', + rating: '5/5', + outcome: 'Planea comidas realistas por 30 días con presupuesto, preferencias, tiempo de preparación y contexto de bienestar con enfoque seguro.', + locked: 'Prompt educativo de planeación incluido después del registro; no reemplaza atención clínica.', + }, + { + title: 'Planeador para influencer', + category: 'Marketing y ventas', + model: 'GPT', + rating: '5/5', + outcome: 'Crea calendario editorial con hooks, guiones, captions, ideas de reutilización y métricas semanales.', + locked: 'El prompt completo de planeación en dos pasos se desbloquea en tu catálogo.', + }, + ], + }, benefitsLabel: 'Beneficios de la biblioteca de prompts', benefits: [ { @@ -103,6 +136,8 @@ export const es = { firstName: 'Nombre', lastName: 'Apellido', email: 'Correo electrónico', + preferredLanguage: 'Idioma de prompts', + preferredLanguageHelp: 'Tus prompts iniciales se guardarán en este idioma. La interfaz se puede cambiar por separado.', creating: 'Creando...', alreadyHaveAccount: '¿Ya tienes una cuenta?', invalidCredentials: 'Credenciales inválidas', @@ -153,6 +188,7 @@ export const es = { emptyRating: 'Sin calificaciones aún', }, form: { + title: 'Título', model: 'Modelo', prompt: 'Texto del prompt', category: 'Categoría', @@ -161,10 +197,12 @@ export const es = { update: 'Actualizar prompt', knownModel: 'Selecciona un modelo conocido de la lista.', invalid: 'Datos del formulario inválidos', + titlePlaceholder: 'Nombra este prompt para encontrarlo fácil después.', promptPlaceholder: 'Pega el prompt que funcionó, incluyendo restricciones o contexto que quieras reutilizar.', }, detail: { title: 'Detalles del prompt', + titleLabel: 'Título', model: 'Modelo', prompt: 'Prompt', category: 'Categoría', @@ -190,7 +228,7 @@ export const es = { }, editTitle: 'Editar prompt como god', allPrompts: 'Todos los prompts', - promptMeta: 'Usuario {{userId}} · {{category}} · Calificación {{rating}}/5', + promptMeta: 'Usuario {{userId}} · {{model}} · {{category}} · Calificación {{rating}}/5', noPrompts: 'No se encontraron prompts.', deleteTitle: '¿Eliminar este prompt?', deleteDescription: 'Esto elimina el prompt seleccionado del catálogo para toda vista de administración.', diff --git a/frontend/src/lib/validation/auth-schemas.ts b/frontend/src/lib/validation/auth-schemas.ts index 4d4ddb5..acda6fa 100644 --- a/frontend/src/lib/validation/auth-schemas.ts +++ b/frontend/src/lib/validation/auth-schemas.ts @@ -11,6 +11,7 @@ export const registerSchema = z.object({ email: z.string().email('Enter a valid email'), username: z.string().min(3, 'Username must be at least 3 characters'), password: z.string().min(6, 'Password must be at least 6 characters'), + preferred_language: z.enum(['es', 'en']), }) export const recoveryRequestSchema = z.object({ diff --git a/frontend/src/lib/validation/prompt-schemas.ts b/frontend/src/lib/validation/prompt-schemas.ts index 0bc74e9..aa725bb 100644 --- a/frontend/src/lib/validation/prompt-schemas.ts +++ b/frontend/src/lib/validation/prompt-schemas.ts @@ -3,6 +3,7 @@ import { z } from 'zod' const PROMPT_TEXT_MAX_CHARS = 1_000_000 export const promptSchema = z.object({ + title: z.string().trim().min(1, 'Title is required').max(120, 'Title must be at most 120 characters'), model_name: z.string().trim().min(1, 'Model name is required'), prompt_text: z .string() diff --git a/frontend/src/pages/admin/admin-prompts-page.tsx b/frontend/src/pages/admin/admin-prompts-page.tsx index 2ed7eb6..e058ed8 100644 --- a/frontend/src/pages/admin/admin-prompts-page.tsx +++ b/frontend/src/pages/admin/admin-prompts-page.tsx @@ -225,10 +225,10 @@ export function AdminPromptsPage() { {(promptsQuery.data ?? []).map((prompt) => (
-

{prompt.model_name}

+

{prompt.title}

{prompt.prompt_text}

- {t('admin.promptMeta', { userId: prompt.user_id, category: prompt.category, rating: prompt.rate })} + {t('admin.promptMeta', { userId: prompt.user_id, model: prompt.model_name, category: prompt.category, rating: prompt.rate })}

{isGod ? ( diff --git a/frontend/src/pages/landing/landing-page.tsx b/frontend/src/pages/landing/landing-page.tsx index 6df0300..e26b70c 100644 --- a/frontend/src/pages/landing/landing-page.tsx +++ b/frontend/src/pages/landing/landing-page.tsx @@ -10,6 +10,14 @@ export function LandingPage() { const { t } = useTranslation() const benefits = t('landing.benefits', { returnObjects: true }) as Array<{ title: string; copy: string }> const workflow = t('landing.workflow', { returnObjects: true }) as string[] + const creatorOffers = t('landing.creatorOffers.cards', { returnObjects: true }) as Array<{ + title: string + category: string + model: string + rating: string + outcome: string + locked: string + }> return (
@@ -78,6 +86,36 @@ export function LandingPage() {
+
+
+
+

{t('landing.creatorOffers.eyebrow')}

+

{t('landing.creatorOffers.title')}

+
+

{t('landing.creatorOffers.copy')}

+
+
+ {creatorOffers.map((offer) => ( + +
+ {offer.model} + {offer.rating} +
+
+

{offer.category}

+

{offer.title}

+

{offer.outcome}

+

{offer.locked}

+
+
+ ))} +
+
+

{t('landing.creatorOffers.included')}

+ {t('landing.creatorOffers.cta')} +
+
+
{benefits.map((benefit) => ( diff --git a/frontend/src/pages/register/register-page.tsx b/frontend/src/pages/register/register-page.tsx index c62119f..66ffdde 100644 --- a/frontend/src/pages/register/register-page.tsx +++ b/frontend/src/pages/register/register-page.tsx @@ -6,17 +6,30 @@ import { Card } from '../../components/ui/card' import { LanguageSwitcher } from '../../components/i18n/language-switcher' import { InlineError } from '../../components/ui/inline-error' import { Input } from '../../components/ui/input' +import { Select } from '../../components/ui/select' import { ThemeToggle } from '../../components/ui/theme-toggle' -import { signup } from '../../features/auth/auth-service' +import { useAuth } from '../../features/auth/auth-store' import { ApiError } from '../../lib/http/api-error' import { registerSchema } from '../../lib/validation/auth-schemas' export function RegisterPage() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const navigate = useNavigate() - const [form, setForm] = useState({ name: '', last_name: '', email: '', username: '', password: '' }) + const { signup } = useAuth() + const [form, setForm] = useState({ + name: '', + last_name: '', + email: '', + username: '', + password: '', + preferred_language: i18n.resolvedLanguage === 'en' ? 'en' : 'es', + }) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + const languageOptions = [ + { value: 'es', label: t('language.spanish') }, + { value: 'en', label: t('language.english') }, + ] const updateField = (field: keyof typeof form, value: string) => { setForm((current) => ({ ...current, [field]: value })) @@ -34,7 +47,7 @@ export function RegisterPage() { setLoading(true) setError(null) await signup(parsed.data) - navigate('/login?registered=1', { replace: true }) + navigate('/app/prompts', { replace: true }) } catch (err) { const message = err instanceof Error ? err.message : t('auth.unableToCreate') setError(err instanceof ApiError && err.status === 400 ? t('auth.usernameTaken') : message) @@ -67,6 +80,13 @@ export function RegisterPage() { updateField('email', event.target.value)} autoComplete="email" /> updateField('username', event.target.value)} autoComplete="username" /> updateField('password', event.target.value)} autoComplete="new-password" /> +