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" /> +