diff --git a/template/app/package.json b/template/app/package.json index 7f4a959b..79f5c35c 100644 --- a/template/app/package.json +++ b/template/app/package.json @@ -13,6 +13,8 @@ "apexcharts": "3.41.0", "clsx": "^2.1.0", "headlessui": "^0.0.0", + "i18next": "^24.1.0", + "i18next-browser-languagedetector": "^8.0.2", "node-fetch": "3.3.0", "openai": "^4.55.3", "prettier": "3.1.1", @@ -22,6 +24,7 @@ "react-router-dom": "^6.26.2", "react-apexcharts": "1.4.1", "react-hot-toast": "^2.4.1", + "react-i18next": "^15.2.0", "react-icons": "4.11.0", "stripe": "11.15.0", "tailwind-merge": "^2.2.1", diff --git a/template/app/src/client/App.tsx b/template/app/src/client/App.tsx index ead2651d..b57cf5c1 100644 --- a/template/app/src/client/App.tsx +++ b/template/app/src/client/App.tsx @@ -1,8 +1,9 @@ import './Main.css'; +import '../i18n'; import NavBar from './components/NavBar/NavBar'; import CookieConsentBanner from './components/cookie-consent/Banner'; -import { appNavigationItems } from './components/NavBar/contentSections'; -import { landingPageNavigationItems } from '../landing-page/contentSections'; +import { useAppNavigationItems } from './components/NavBar/contentSections'; +import { useLandingPageNavigationItems } from '../landing-page/contentSections'; import { useMemo, useEffect } from 'react'; import { routes } from 'wasp/client/router'; import { Outlet, useLocation } from 'react-router-dom'; @@ -18,7 +19,11 @@ export default function App() { const location = useLocation(); const { data: user } = useAuth(); const isLandingPage = useIsLandingPage(); - const navigationItems = isLandingPage ? landingPageNavigationItems : appNavigationItems; + + // Use the new hook instead of direct array + const appNavItems = useAppNavigationItems(); + const landingPageNavItems = useLandingPageNavigationItems(); + const navigationItems = isLandingPage ? landingPageNavItems : appNavItems; const shouldDisplayAppNavBar = useMemo(() => { return location.pathname !== routes.LoginRoute.build() && location.pathname !== routes.SignupRoute.build(); diff --git a/template/app/src/client/components/LanguageSelector.tsx b/template/app/src/client/components/LanguageSelector.tsx new file mode 100644 index 00000000..5113aff9 --- /dev/null +++ b/template/app/src/client/components/LanguageSelector.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { BiGlobe } from 'react-icons/bi'; +import { cn } from '../cn'; + +interface LanguageSelectorProps { + isLandingPage?: boolean; + className?: string; +} + +export default function LanguageSelector({ isLandingPage, className }: LanguageSelectorProps) { + const { i18n } = useTranslation(); + + // Initialize language from localStorage or default to 'en' + React.useEffect(() => { + const savedLanguage = localStorage.getItem('language') || 'en'; + i18n.changeLanguage(savedLanguage); + }, [i18n]); + + const changeLanguage = (lng: string) => { + localStorage.setItem('language', lng); + i18n.changeLanguage(lng); + }; + + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/template/app/src/client/components/NavBar/NavBar.tsx b/template/app/src/client/components/NavBar/NavBar.tsx index a4fed880..6c87a58e 100644 --- a/template/app/src/client/components/NavBar/NavBar.tsx +++ b/template/app/src/client/components/NavBar/NavBar.tsx @@ -12,6 +12,7 @@ import { UserMenuItems } from '../../../user/UserMenuItems'; import DarkModeSwitcher from '../DarkModeSwitcher'; import { useIsLandingPage } from '../../hooks/useIsLandingPage'; import { cn } from '../../cn'; +import LanguageSelector from '../LanguageSelector'; export interface NavigationItem { name: string; @@ -58,6 +59,7 @@ export default function AppNavBar({ navigationItems }: { navigationItems: Naviga
{isUserLoading ? null : !user ? ( @@ -105,6 +107,9 @@ export default function AppNavBar({ navigationItems }: { navigationItems: Naviga
+
+ +
diff --git a/template/app/src/client/components/NavBar/contentSections.ts b/template/app/src/client/components/NavBar/contentSections.ts index 907ed1a3..fbf2abec 100644 --- a/template/app/src/client/components/NavBar/contentSections.ts +++ b/template/app/src/client/components/NavBar/contentSections.ts @@ -1,11 +1,35 @@ import type { NavigationItem } from '../NavBar/NavBar'; import { routes } from 'wasp/client/router'; import { BlogUrl, DocsUrl } from '../../../shared/common'; +import { useTranslation } from 'react-i18next'; -export const appNavigationItems: NavigationItem[] = [ - { name: 'AI Scheduler (Demo App)', to: routes.DemoAppRoute.to }, - { name: 'File Upload (AWS S3)', to: routes.FileUploadRoute.to }, - { name: 'Pricing', to: routes.PricingPageRoute.to }, - { name: 'Documentation', to: DocsUrl }, - { name: 'Blog', to: BlogUrl }, -]; +export const useAppNavigationItems = (): NavigationItem[] => { + const { t, i18n } = useTranslation(); + + // Debug log + console.log('Current language:', i18n.language); + console.log('Translation test:', t('navigation.aiScheduler')); + + return [ + { + name: t('navigation.aiScheduler'), + to: routes.DemoAppRoute.to + }, + { + name: t('navigation.fileUpload'), + to: routes.FileUploadRoute.to + }, + { + name: t('navigation.pricing'), + to: routes.PricingPageRoute.to + }, + { + name: t('navigation.documentation'), + to: DocsUrl + }, + { + name: t('navigation.blog'), + to: BlogUrl + }, + ]; +}; diff --git a/template/app/src/demo-ai-app/DemoAppPage.tsx b/template/app/src/demo-ai-app/DemoAppPage.tsx index a14dc546..64823f5a 100644 --- a/template/app/src/demo-ai-app/DemoAppPage.tsx +++ b/template/app/src/demo-ai-app/DemoAppPage.tsx @@ -14,95 +14,96 @@ import { CgSpinner } from 'react-icons/cg'; import { TiDelete } from 'react-icons/ti'; import type { GeneratedSchedule, MainTask, SubTask } from './schedule'; import { cn } from '../client/cn'; +import { useTranslation } from 'react-i18next'; export default function DemoAppPage() { + const { t } = useTranslation(); + return (

- AI Day Scheduler + AI {t('aiScheduler.title')}

- This example app uses OpenAI's chat completions with function calling to return a structured JSON object. Try - it out, enter your day's tasks, and let AI do the rest! + {t('aiScheduler.description')}

- {/* begin AI-powered Todo List */}
- {/* end AI-powered Todo List */}
); } function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask }) { + const { t } = useTranslation(); const [description, setDescription] = useState(''); const [todaysHours, setTodaysHours] = useState('8'); const [response, setResponse] = useState({ mainTasks: [ { - name: 'Respond to emails', + name: t('aiScheduler.defaultTasks.email.name'), priority: 'high', }, { - name: 'Learn WASP', + name: t('aiScheduler.defaultTasks.wasp.name'), priority: 'low', }, { - name: 'Read a book', + name: t('aiScheduler.defaultTasks.book.name'), priority: 'medium', }, ], subtasks: [ { - description: 'Read introduction and chapter 1', + description: t('aiScheduler.defaultTasks.book.subtasks.intro.description'), time: 0.5, - mainTaskName: 'Read a book', + mainTaskName: t('aiScheduler.defaultTasks.book.name'), }, { - description: 'Read chapter 2 and take notes', + description: t('aiScheduler.defaultTasks.book.subtasks.chapter2.description'), time: 0.3, - mainTaskName: 'Read a book', + mainTaskName: t('aiScheduler.defaultTasks.book.name'), }, { - description: 'Read chapter 3 and summarize key points', + description: t('aiScheduler.defaultTasks.book.subtasks.chapter3.description'), time: 0.2, - mainTaskName: 'Read a book', + mainTaskName: t('aiScheduler.defaultTasks.book.name'), }, { - description: 'Check and respond to important emails', + description: t('aiScheduler.defaultTasks.email.subtasks.check.description'), time: 1, - mainTaskName: 'Respond to emails', + mainTaskName: t('aiScheduler.defaultTasks.email.name'), }, { - description: 'Organize and prioritize remaining emails', + description: t('aiScheduler.defaultTasks.email.subtasks.organize.description'), time: 0.5, - mainTaskName: 'Respond to emails', + mainTaskName: t('aiScheduler.defaultTasks.email.name'), }, { - description: 'Draft responses to urgent emails', + description: t('aiScheduler.defaultTasks.email.subtasks.draft.description'), time: 0.5, - mainTaskName: 'Respond to emails', + mainTaskName: t('aiScheduler.defaultTasks.email.name'), }, { - description: 'Watch tutorial video on WASP', + description: t('aiScheduler.defaultTasks.wasp.subtasks.watch.description'), time: 0.5, - mainTaskName: 'Learn WASP', + mainTaskName: t('aiScheduler.defaultTasks.wasp.name'), }, { - description: 'Complete online quiz on the basics of WASP', + description: t('aiScheduler.defaultTasks.wasp.subtasks.quiz.description'), time: 1.5, - mainTaskName: 'Learn WASP', + mainTaskName: t('aiScheduler.defaultTasks.wasp.name'), }, { - description: 'Review quiz answers and clarify doubts', + description: t('aiScheduler.defaultTasks.wasp.subtasks.review.description'), time: 1, - mainTaskName: 'Learn WASP', + mainTaskName: t('aiScheduler.defaultTasks.wasp.name'), }, ], }); @@ -115,7 +116,7 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask await handleCreateTask({ description }); setDescription(''); } catch (err: any) { - window.alert('Error: ' + (err.message || 'Something went wrong')); + window.alert(t('aiScheduler.form.errors.createTask') + ': ' + (err.message || t('aiScheduler.form.errors.generic'))); } }; @@ -129,7 +130,7 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask setResponse(response); } } catch (err: any) { - window.alert('Error: ' + (err.message || 'Something went wrong')); + window.alert(t('aiScheduler.form.errors.generic') + ': ' + (err.message || t('aiScheduler.form.errors.generic'))); } finally { setIsPlanGenerating(false); } @@ -143,7 +144,7 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask type='text' id='description' className='text-sm text-gray-600 w-full rounded-md border border-gray-200 bg-[#f5f0ff] shadow-md focus:outline-none focus:border-transparent focus:shadow-none duration-200 ease-in-out hover:shadow-none' - placeholder='Enter task description' + placeholder={t('aiScheduler.form.taskPlaceholder')} value={description} onChange={(e) => setDescription(e.currentTarget.value)} onKeyDown={(e) => { @@ -152,7 +153,7 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask } }} /> -
- {isTasksLoading &&
Loading...
} - {tasks!! && tasks.length > 0 ? ( + {isTasksLoading &&
{t('aiScheduler.form.loading')}
} + {tasks && tasks.length > 0 ? (
{tasks.map((task: Task) => ( @@ -176,7 +177,7 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
) : ( -
Add tasks to begin
+
{t('aiScheduler.form.noTasks')}
)}
@@ -205,17 +206,18 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask {isPlanGenerating ? ( <> - Generating... + {t('aiScheduler.form.generatingButton')} ) : ( - 'Generate Schedule' + t('aiScheduler.form.generateButton') )} {!!response && (
-

Today's Schedule

- +

+ {t('aiScheduler.form.scheduleTitle')} +

)} @@ -226,6 +228,8 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask type TodoProps = Pick; function Todo({ id, isDone, description, time }: TodoProps) { + const { t } = useTranslation(); + const handleCheckboxChange = async (e: React.ChangeEvent) => { await updateTask({ id, @@ -253,12 +257,9 @@ function Todo({ id, isDone, description, time }: TodoProps) { className='ml-1 form-checkbox bg-purple-300 checked:bg-purple-300 rounded border-purple-400 duration-200 ease-in-out hover:bg-purple-400 hover:checked:bg-purple-600 focus:ring focus:ring-purple-300 focus:checked:bg-purple-400 focus:ring-opacity-50 text-black' checked={isDone} onChange={handleCheckboxChange} + aria-label={t('aiScheduler.todo.checkbox.ariaLabel')} /> - + {description}
@@ -270,24 +271,22 @@ function Todo({ id, isDone, description, time }: TodoProps) { step={0.5} className={cn( 'w-18 h-8 text-center text-slate-600 text-xs rounded border border-gray-200 focus:outline-none focus:border-transparent focus:ring-2 focus:ring-purple-300 focus:ring-opacity-50', - { - 'pointer-events-none opacity-50': isDone, - } + { 'pointer-events-none opacity-50': isDone } )} value={time} onChange={handleTimeChange} /> - - hrs + + {t('aiScheduler.todo.time.hours')}
-
@@ -296,49 +295,50 @@ function Todo({ id, isDone, description, time }: TodoProps) { } function TaskTable({ schedule }: { schedule: GeneratedSchedule }) { + const { t } = useTranslation(); + return (
{!!schedule.mainTasks ? ( schedule.mainTasks - .map((mainTask) => ) + .map((mainTask) => ( + + )) .sort((a, b) => { const priorityOrder = ['low', 'medium', 'high']; if (a.props.mainTask.priority && b.props.mainTask.priority) { - return ( - priorityOrder.indexOf(b.props.mainTask.priority) - priorityOrder.indexOf(a.props.mainTask.priority) - ); - } else { - return 0; + return priorityOrder.indexOf(b.props.mainTask.priority) - priorityOrder.indexOf(a.props.mainTask.priority); } + return 0; }) ) : ( -
OpenAI didn't return any Main Tasks. Try again.
+
{t('aiScheduler.taskTable.noMainTasks')}
)}
- - {/* ))} */}
); } function MainTaskTable({ mainTask, subtasks }: { mainTask: MainTask; subtasks: SubTask[] }) { + const { t } = useTranslation(); + return ( <> - + {mainTask.name} - {mainTask.priority} priority + + {t(`aiScheduler.taskTable.priority.${mainTask.priority}`)} + @@ -348,16 +348,14 @@ function MainTaskTable({ mainTask, subtasks }: { mainTask: MainTask; subtasks: S return ( - + @@ -366,23 +364,31 @@ function MainTaskTable({ mainTask, subtasks }: { mainTask: MainTask; subtasks: S } }) ) : ( -
OpenAI didn't return any Subtasks. Try again.
+
{t('aiScheduler.taskTable.noSubtasks')}
)} ); } function SubtaskTable({ description, time }: { description: string; time: number }) { + const { t } = useTranslation(); const [isDone, setIsDone] = useState(false); const convertHrsToMinutes = (time: number) => { if (time === 0) return 0; const hours = Math.floor(time); const minutes = Math.round((time - hours) * 60); - return `${hours > 0 ? hours + 'hr' : ''} ${minutes > 0 ? minutes + 'min' : ''}`; + + if (hours > 0 && minutes > 0) { + return t('aiScheduler.timeFormat.timeDisplay', { hours, minutes }); + } else if (hours > 0) { + return t('aiScheduler.timeFormat.hourOnly', { hours }); + } else { + return t('aiScheduler.timeFormat.minuteOnly', { minutes }); + } }; - const minutes = useMemo(() => convertHrsToMinutes(time), [time]); + const minutes = useMemo(() => convertHrsToMinutes(time), [time, t]); return ( <> @@ -391,19 +397,16 @@ function SubtaskTable({ description, time }: { description: string; time: number className='ml-1 form-checkbox bg-purple-500 checked:bg-purple-300 rounded border-purple-600 duration-200 ease-in-out hover:bg-purple-600 hover:checked:bg-purple-600 focus:ring focus:ring-purple-300 focus:checked:bg-purple-400 focus:ring-opacity-50' checked={isDone} onChange={(e) => setIsDone(e.currentTarget.checked)} + aria-label={t('aiScheduler.todo.checkbox.ariaLabel')} /> - + {description} - + {minutes} diff --git a/template/app/src/file-upload/FileUploadPage.tsx b/template/app/src/file-upload/FileUploadPage.tsx index 74c71458..480cea56 100644 --- a/template/app/src/file-upload/FileUploadPage.tsx +++ b/template/app/src/file-upload/FileUploadPage.tsx @@ -1,37 +1,28 @@ -import { cn } from '../client/cn'; +import { createFile, useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations'; +import axios from 'axios'; import { useState, useEffect, FormEvent } from 'react'; -import type { File } from 'wasp/entities'; -import { useQuery, getAllFilesByUser, getDownloadFileSignedURL } from 'wasp/client/operations'; -import { type FileUploadError, uploadFileWithProgress, validateFile, ALLOWED_FILE_TYPES } from './fileUploading'; +import { cn } from '../client/cn'; +import { useTranslation } from 'react-i18next'; export default function FileUploadPage() { - const [fileKeyForS3, setFileKeyForS3] = useState(''); - const [uploadProgressPercent, setUploadProgressPercent] = useState(0); - const [uploadError, setUploadError] = useState(null); + const { t } = useTranslation(); + const [fileToDownload, setFileToDownload] = useState(''); - const allUserFiles = useQuery(getAllFilesByUser, undefined, { - // We disable automatic refetching because otherwise files would be refetched after `createFile` is called and the S3 URL is returned, - // which happens before the file is actually fully uploaded. Instead, we manually (re)fetch on mount and after the upload is complete. - enabled: false, - }); + const { data: files, error: filesError, isLoading: isFilesLoading } = useQuery(getAllFilesByUser); const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } = useQuery( getDownloadFileSignedURL, - { key: fileKeyForS3 }, + { key: fileToDownload }, { enabled: false } ); useEffect(() => { - allUserFiles.refetch(); - }, []); - - useEffect(() => { - if (fileKeyForS3.length > 0) { + if (fileToDownload.length > 0) { refetchDownloadUrl() .then((urlQuery) => { switch (urlQuery.status) { case 'error': console.error('Error fetching download URL', urlQuery.error); - alert('Error fetching download'); + alert(t('fileUpload.errors.downloadError.fetchFailed')); return; case 'success': window.open(urlQuery.data, '_blank'); @@ -39,49 +30,38 @@ export default function FileUploadPage() { } }) .finally(() => { - setFileKeyForS3(''); + setFileToDownload(''); }); } - }, [fileKeyForS3]); + }, [fileToDownload, t]); const handleUpload = async (e: FormEvent) => { try { e.preventDefault(); - - const formElement = e.target; - if (!(formElement instanceof HTMLFormElement)) { - throw new Error('Event target is not a form element'); + const formData = new FormData(e.target as HTMLFormElement); + const file = formData.get('file-upload') as File; + if (!file || !file.name || !file.type) { + throw new Error(t('fileUpload.errors.uploadError.noFile')); } - const formData = new FormData(formElement); - const file = formData.get('file-upload'); + const fileType = file.type; + const name = file.name; - if (!file || !(file instanceof File)) { - setUploadError({ - message: 'Please select a file to upload.', - code: 'NO_FILE', - }); - return; + const { uploadUrl } = await createFile({ fileType, name }); + if (!uploadUrl) { + throw new Error(t('fileUpload.errors.uploadError.uploadFailed')); } - - const validationError = validateFile(file); - if (validationError) { - setUploadError(validationError); - return; + const res = await axios.put(uploadUrl, file, { + headers: { + 'Content-Type': fileType, + }, + }); + if (res.status !== 200) { + throw new Error(t('fileUpload.errors.uploadError.s3Failed')); } - - await uploadFileWithProgress({ file, setUploadProgressPercent }); - formElement.reset(); - allUserFiles.refetch(); } catch (error) { - console.error('Error uploading file:', error); - setUploadError({ - message: - error instanceof Error ? error.message : 'An unexpected error occurred while uploading the file.', - code: 'UPLOAD_FAILED', - }); - } finally { - setUploadProgressPercent(0); + alert(t('fileUpload.errors.uploadError.generic')); + console.error('Error uploading file', error); } }; @@ -90,75 +70,55 @@ export default function FileUploadPage() {

- AWS File Upload + AWS {t('fileUpload.title')}

- This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a - lot of people asked for this feature, so here you go 🤝 + {t('fileUpload.subtitle')}

setUploadError(null)} /> - {uploadError &&
{uploadError.message}
}
-

Uploaded Files

- {allUserFiles.isLoading &&

Loading...

} - {allUserFiles.error &&

Error: {allUserFiles.error.message}

} - {!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? ( - allUserFiles.data.map((file: File) => ( +

{t('fileUpload.uploadedFiles')}

+ {isFilesLoading &&

{t('fileUpload.loading')}

} + {filesError &&

Error: {filesError.message}

} + {!!files && files.length > 0 ? ( + files.map((file: any) => (

{file.name}

)) ) : ( -

No files uploaded yet :(

+

{t('fileUpload.noFiles')}

)}
diff --git a/template/app/src/i18n.ts b/template/app/src/i18n.ts new file mode 100644 index 00000000..b7fc4bbd --- /dev/null +++ b/template/app/src/i18n.ts @@ -0,0 +1,654 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +const resources = { + en: { + translation: { + "navigation": { + "features": "Features", + "pricing": "Pricing", + "documentation": "Documentation", + "blog": "Blog", + "aiScheduler": "AI Scheduler (Demo App)", + "fileUpload": "File Upload (AWS S3)" + }, + "hero": { + "title": "Some cool words about your product", + "subtitle": "With some more exciting words about your product!", + "cta": "Get Started" + }, + "features": { + "title": "The Best Features", + "subtitle": "Don't work harder.\nWork smarter.", + "feature1": { + "name": "Cool Feature #1", + "description": "Describe your cool feature here." + }, + "feature2": { + "name": "Cool Feature #2", + "description": "Describe your cool feature here." + }, + "feature3": { + "name": "Cool Feature #3", + "description": "Describe your cool feature here." + }, + "feature4": { + "name": "Cool Feature #4", + "description": "Describe your cool feature here." + } + }, + "clients": { + "title": "Built with / Used by:" + }, + "testimonials": { + "title": "What Our Users Say", + "daBoi": { + "name": "Da Boi", + "role": "Wasp Mascot", + "quote": "I don't even know how to code. I'm just a plushie." + }, + "mrFoobar": { + "name": "Mr. Foobar", + "role": "Founder @ Cool Startup", + "quote": "This product makes me cooler than I already am." + }, + "jamie": { + "name": "Jamie", + "role": "Happy Customer", + "quote": "My cats love it!" + } + }, + "faqs": { + "title": "Frequently asked questions", + "question1": { + "question": "What's the meaning of life?", + "answer": "42." + } + }, + "footer": { + "app": { + "documentation": "Documentation", + "blog": "Blog" + }, + "company": { + "about": "About", + "privacy": "Privacy", + "terms": "Terms of Service" + } + }, + "common": { + "learnMore": "Learn more" + }, + "fileUpload": { + "title": "File Upload", + "subtitle": "This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a lot of people asked for this feature, so here you go 🤝", + "uploadButton": "Upload", + "uploadedFiles": "Uploaded Files", + "loading": "Loading...", + "noFiles": "No files uploaded yet :(", + "downloadButton": "Download", + "errors": { + "uploadError": { + "noFile": "Please select a file to upload", + "uploadFailed": "Failed to upload file. Please try again", + "s3Failed": "File upload to storage failed. Please try again", + "generic": "An error occurred while uploading. Please try again" + }, + "downloadError": { + "fetchFailed": "Unable to generate download link. Please try again", + "generic": "An error occurred while downloading. Please try again" + } + } + }, + "aiScheduler": { + "title": "Day Scheduler", + "description": "This example app uses OpenAI's chat completions with function calling to return a structured JSON object. Try it out, enter your day's tasks, and let AI do the rest!", + "form": { + "taskPlaceholder": "Enter task description", + "addTaskButton": "Add Task", + "hoursLabel": "How many hours will you work today?", + "noTasks": "Add tasks to begin", + "generateButton": "Generate Schedule", + "generatingButton": "Generating...", + "scheduleTitle": "Today's Schedule", + "loading": "Loading...", + "errors": { + "generic": "Error: Something went wrong", + "createTask": "Error creating task" + }, + "time": { + "hours": "hrs", + "minutes": "min", + "hour": "hr" + }, + "priority": { + "high": "high priority", + "medium": "medium priority", + "low": "low priority" + }, + "removeTask": "Remove task", + "checkbox": { + "ariaLabel": "Mark task as done" + } + }, + "task": { + "hours": "hrs", + "removeTask": "Remove task" + }, + "schedule": { + "priority": { + "high": "high priority", + "medium": "medium priority", + "low": "low priority" + }, + "noMainTasks": "OpenAI didn't return any Main Tasks. Try again.", + "noSubtasks": "OpenAI didn't return any Subtasks. Try again." + }, + "todo": { + "removeTaskTitle": "Remove task", + "time": { + "hours": "hrs", + "minutes": "min", + "hour": "hr" + }, + "checkbox": { + "ariaLabel": "Mark task as done" + } + }, + "taskTable": { + "noMainTasks": "OpenAI didn't return any Main Tasks. Try again.", + "noSubtasks": "OpenAI didn't return any Subtasks. Try again.", + "priority": { + "high": "high priority", + "medium": "medium priority", + "low": "low priority" + }, + "timeFormats": { + "1hr": "1hr", + "30min": "30min", + "18min": "18min", + "12min": "12min", + "1hr30min": "1hr 30min" + } + }, + "defaultTasks": { + "email": { + "name": "Respond to emails", + "priority": "high priority", + "subtasks": { + "check": { + "description": "Check and respond to important emails", + "time": "1hr" + }, + "organize": { + "description": "Organize and prioritize remaining emails", + "time": "30min" + }, + "draft": { + "description": "Draft responses to urgent emails", + "time": "30min" + } + } + }, + "book": { + "name": "Read a book", + "priority": "medium priority", + "subtasks": { + "intro": { + "description": "Read introduction and chapter 1", + "time": "30min" + }, + "chapter2": { + "description": "Read chapter 2 and take notes", + "time": "18min" + }, + "chapter3": { + "description": "Read chapter 3 and summarize key points", + "time": "12min" + } + } + }, + "wasp": { + "name": "Learn WASP", + "priority": "low priority", + "subtasks": { + "watch": { + "description": "Watch tutorial video on WASP", + "time": "30min" + }, + "quiz": { + "description": "Complete online quiz on the basics of WASP", + "time": "1hr 30min" + }, + "review": { + "description": "Review quiz answers and clarify doubts", + "time": "1hr" + } + } + } + }, + "timeFormat": { + "hour": "hr", + "hours": "hrs", + "minute": "min", + "minutes": "min", + "shortHour": "hr", + "shortMinute": "min", + "timeDisplay": "{{hours}}hr {{minutes}}min", + "hourOnly": "{{hours}}hr", + "minuteOnly": "{{minutes}}min" + } + }, + "pricing": { + "title": "Pick your pricing", + "subtitle": "Choose between Stripe and LemonSqueezy as your payment provider. Just add your Product IDs! Try it out below with test credit card number", + "testCard": "4242 4242 4242 4242 4242", + "plans": { + "hobby": { + "name": "Hobby", + "price": "$9.99", + "description": "All you need to get started", + "features": [ + "Limited monthly usage", + "Basic support" + ] + }, + "pro": { + "name": "Pro", + "price": "$19.99", + "description": "Our most popular plan", + "features": [ + "Unlimited monthly usage", + "Priority customer support" + ] + }, + "credits10": { + "name": "10 Credits", + "price": "$9.99", + "description": "One-time purchase of 10 credits for your account", + "features": [ + "Use credits for e.g. OpenAI API calls", + "No expiration date" + ] + } + }, + "buttons": { + "manageSubscription": "Manage Subscription", + "buyPlan": "Buy plan", + "loginToBuy": "Log in to buy plan" + }, + "perMonth": "/month" + }, + "account": { + "title": "Account Information", + "email": "Email address", + "username": "Username", + "plan": "Your Plan", + "about": "About", + "aboutText": "I'm a cool customer.", + "logout": "logout", + "subscription": { + "credits": "Credits remaining: {{credits}}", + "buyMore": "Buy More/Upgrade", + "manageSubscription": "Manage Subscription", + "status": { + "active": "{{planName}}", + "past_due": "Payment for your {{planName}} plan is past due! Please update your subscription payment information.", + "cancel_at_period_end": "Your {{planName}} plan subscription has been canceled, but remains active until the end of the current billing period: {{date}}", + "deleted": "Your previous subscription has been canceled and is no longer active." + }, + "errors": { + "portalUrl": "Error fetching customer portal url", + "portalNotAvailable": "Customer portal URL is not available" + } + } + }, + "userMenu": { + "aiScheduler": "AI Scheduler (Demo App)", + "accountSettings": "Account Settings", + "adminDashboard": "Admin Dashboard", + "logout": "Log Out" + } + } + }, + es: { + translation: { + "navigation": { + "features": "Características", + "pricing": "Precios", + "documentation": "Documentación", + "blog": "Blog", + "aiScheduler": "Planificador IA (App Demo)", + "fileUpload": "Subida de Archivos" + }, + "hero": { + "title": "Algunas palabras geniales sobre tu producto", + "subtitle": "¡Con más palabras emocionantes sobre tu producto!", + "cta": "Comenzar" + }, + "features": { + "title": "Las Mejores Características", + "subtitle": "No trabajes más duro.\nTrabaja más inteligente.", + "feature1": { + "name": "Función Genial #1", + "description": "Describe tu función genial aquí." + }, + "feature2": { + "name": "Función Genial #2", + "description": "Describe tu función genial aquí." + }, + "feature3": { + "name": "Función Genial #3", + "description": "Describe tu función genial aquí." + }, + "feature4": { + "name": "Función Genial #4", + "description": "Describe tu función genial aquí." + } + }, + "clients": { + "title": "Construido con / Usado por:" + }, + "testimonials": { + "title": "Lo que dicen nuestros usuarios", + "daBoi": { + "name": "Da Boi", + "role": "Mascota de Wasp", + "quote": "Ni siquiera sé programar. Solo soy un peluche." + }, + "mrFoobar": { + "name": "Sr. Foobar", + "role": "Fundador @ Startup Genial", + "quote": "Este producto me hace más genial de lo que ya soy." + }, + "jamie": { + "name": "Jamie", + "role": "Cliente Satisfecho", + "quote": "¡A mis gatos les encanta!" + } + }, + "faqs": { + "title": "Preguntas frecuentes", + "question1": { + "question": "¿Cuál es el sentido de la vida?", + "answer": "42." + } + }, + "footer": { + "app": { + "documentation": "Documentación", + "blog": "Blog" + }, + "company": { + "about": "Acerca de", + "privacy": "Privacidad", + "terms": "Términos de Servicio" + } + }, + "common": { + "learnMore": "Saber más" + }, + "fileUpload": { + "title": "Subida de Archivos", + "subtitle": "Esta es una página de ejemplo para subir archivos usando AWS S3. Tal vez tu aplicación lo necesite. Tal vez no. Pero mucha gente pidió esta función, así que aquí la tienes 🤝", + "uploadButton": "Subir", + "uploadedFiles": "Archivos Subidos", + "loading": "Cargando...", + "noFiles": "Aún no hay archivos subidos :(", + "downloadButton": "Descargar", + "errors": { + "uploadError": { + "noFile": "Por favor, selecciona un archivo para subir", + "uploadFailed": "Error al subir el archivo. Por favor, inténtalo de nuevo", + "s3Failed": "Error al subir el archivo al almacenamiento. Por favor, inténtalo de nuevo", + "generic": "Ocurrió un error durante la subida. Por favor, inténtalo de nuevo" + }, + "downloadError": { + "fetchFailed": "No se pudo generar el enlace de descarga. Por favor, inténtalo de nuevo", + "generic": "Ocurrió un error durante la descarga. Por favor, inténtalo de nuevo" + } + } + }, + "aiScheduler": { + "title": "Planificador Diario", + "description": "Esta aplicación de ejemplo utiliza las completaciones de chat de OpenAI con llamadas a funciones para devolver un objeto JSON estructurado. ¡Pruébalo, ingresa tus tareas del día y deja que la IA haga el resto!", + "form": { + "taskPlaceholder": "Ingresa la descripción de la tarea", + "addTaskButton": "Agregar Tarea", + "hoursLabel": "¿Cuántas horas trabajarás hoy?", + "noTasks": "Agrega tareas para comenzar", + "generateButton": "Generar Horario", + "generatingButton": "Generando...", + "scheduleTitle": "Horario de Hoy", + "loading": "Cargando...", + "errors": { + "generic": "Error: Algo salió mal", + "createTask": "Error al crear la tarea" + }, + "time": { + "hours": "hrs", + "minutes": "min", + "hour": "hr" + }, + "priority": { + "high": "prioridad alta", + "medium": "prioridad media", + "low": "prioridad baja" + }, + "removeTask": "Eliminar tarea", + "checkbox": { + "ariaLabel": "Marcar tarea como completada" + } + }, + "task": { + "hours": "hrs", + "removeTask": "Eliminar tarea" + }, + "schedule": { + "priority": { + "high": "prioridad alta", + "medium": "prioridad media", + "low": "prioridad baja" + }, + "noMainTasks": "OpenAI no devolvió ninguna Tarea Principal. Inténtalo de nuevo.", + "noSubtasks": "OpenAI no devolvió ninguna Subtarea. Inténtalo de nuevo." + }, + "todo": { + "removeTaskTitle": "Eliminar tarea", + "time": { + "hours": "hrs", + "minutes": "min", + "hour": "hr" + }, + "checkbox": { + "ariaLabel": "Marcar tarea como completada" + } + }, + "taskTable": { + "noMainTasks": "OpenAI no devolvió ninguna Tarea Principal. Inténtalo de nuevo.", + "noSubtasks": "OpenAI no devolvió ninguna Subtarea. Inténtalo de nuevo.", + "priority": { + "high": "prioridad alta", + "medium": "prioridad media", + "low": "prioridad baja" + }, + "timeFormats": { + "1hr": "1h", + "30min": "30min", + "18min": "18min", + "12min": "12min", + "1hr30min": "1h 30min" + } + }, + "defaultTasks": { + "email": { + "name": "Responder correos", + "priority": "prioridad alta", + "subtasks": { + "check": { + "description": "Revisar y responder correos importantes", + "time": "1h" + }, + "organize": { + "description": "Organizar y priorizar correos restantes", + "time": "30min" + }, + "draft": { + "description": "Redactar respuestas a correos urgentes", + "time": "30min" + } + } + }, + "book": { + "name": "Leer un libro", + "priority": "prioridad media", + "subtasks": { + "intro": { + "description": "Leer introducción y capítulo 1", + "time": "30min" + }, + "chapter2": { + "description": "Leer capítulo 2 y tomar notas", + "time": "18min" + }, + "chapter3": { + "description": "Leer capítulo 3 y resumir puntos clave", + "time": "12min" + } + } + }, + "wasp": { + "name": "Aprender WASP", + "priority": "prioridad baja", + "subtasks": { + "watch": { + "description": "Ver video tutorial sobre WASP", + "time": "30min" + }, + "quiz": { + "description": "Completar cuestionario en línea sobre conceptos básicos de WASP", + "time": "1h 30min" + }, + "review": { + "description": "Revisar respuestas del cuestionario y aclarar dudas", + "time": "1h" + } + } + } + }, + "timeFormat": { + "hour": "hora", + "hours": "horas", + "minute": "min", + "minutes": "min", + "shortHour": "h", + "shortMinute": "min", + "timeDisplay": "{{hours}}h {{minutes}}min", + "hourOnly": "{{hours}}h", + "minuteOnly": "{{minutes}}min" + } + }, + "pricing": { + "title": "Elige tu precio", + "subtitle": "Elige entre Stripe y LemonSqueezy como tu proveedor de pagos. ¡Solo agrega tus ID de Producto! Pruébalo a continuación con el número de tarjeta de crédito de prueba", + "testCard": "4242 4242 4242 4242 4242", + "plans": { + "hobby": { + "name": "Hobby", + "price": "$9.99", + "description": "Todo lo que necesitas para comenzar", + "features": [ + "Uso mensual limitado", + "Soporte básico" + ] + }, + "pro": { + "name": "Pro", + "price": "$19.99", + "description": "Nuestro plan más popular", + "features": [ + "Uso mensual ilimitado", + "Soporte prioritario al cliente" + ] + }, + "credits10": { + "name": "10 Créditos", + "price": "$9.99", + "description": "Compra única de 10 créditos para tu cuenta", + "features": [ + "Usa créditos para llamadas a la API de OpenAI", + "Sin fecha de vencimiento" + ] + } + }, + "buttons": { + "manageSubscription": "Gestionar Suscripción", + "buyPlan": "Comprar plan", + "loginToBuy": "Inicia sesión para comprar" + }, + "perMonth": "/mes" + }, + "account": { + "title": "Información de la Cuenta", + "email": "Correo electrónico", + "username": "Nombre de usuario", + "plan": "Tu Plan", + "about": "Acerca de", + "aboutText": "Soy un cliente genial.", + "logout": "cerrar sesión", + "subscription": { + "credits": "Créditos restantes: {{credits}}", + "buyMore": "Comprar Más/Mejorar", + "manageSubscription": "Gestionar Suscripción", + "status": { + "active": "{{planName}}", + "past_due": "¡El pago de tu plan {{planName}} está vencido! Por favor, actualiza tu información de pago de suscripción.", + "cancel_at_period_end": "Tu suscripción al plan {{planName}} ha sido cancelada, pero permanece activa hasta el final del período de facturación actual: {{date}}", + "deleted": "Tu suscripción anterior ha sido cancelada y ya no está activa." + }, + "errors": { + "portalUrl": "Error al obtener la URL del portal del cliente", + "portalNotAvailable": "La URL del portal del cliente no está disponible" + } + } + }, + "userMenu": { + "aiScheduler": "Planificador IA (App Demo)", + "accountSettings": "Ajustes de Cuenta", + "adminDashboard": "Panel de Administrador", + "logout": "Cerrar Sesión" + } + } + } +}; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'en', + supportedLngs: ['en', 'es'], + debug: true, + detection: { + order: ['navigator', 'htmlTag', 'path', 'subdomain'], + caches: ['localStorage', 'cookie'], + cookieMinutes: 160, + lookupCookie: 'i18next', + lookupLocalStorage: 'i18nextLng', + }, + interpolation: { + escapeValue: false + } + }); + +i18n.on('initialized', (options) => { + console.log('i18n initialized:', options); +}); + +i18n.on('languageChanged', (lng) => { + console.log('Language changed to:', lng); +}); + +export default i18n; \ No newline at end of file diff --git a/template/app/src/landing-page/LandingPage.tsx b/template/app/src/landing-page/LandingPage.tsx index ac865a8d..ac5006f9 100644 --- a/template/app/src/landing-page/LandingPage.tsx +++ b/template/app/src/landing-page/LandingPage.tsx @@ -1,4 +1,4 @@ -import { features, faqs, footerNavigation, testimonials } from './contentSections'; +import { useFeatures, useTestimonials, useFaqs, useFooterNavigation } from './contentSections'; import Hero from './components/Hero'; import Clients from './components/Clients'; import Features from './components/Features'; @@ -7,6 +7,11 @@ import FAQ from './components/FAQ'; import Footer from './components/Footer'; export default function LandingPage() { + const features = useFeatures(); + const testimonials = useTestimonials(); + const faqs = useFaqs(); + const footerNavigation = useFooterNavigation(); + return (
diff --git a/template/app/src/landing-page/components/FAQ.tsx b/template/app/src/landing-page/components/FAQ.tsx index 1bac30e8..8ac89f69 100644 --- a/template/app/src/landing-page/components/FAQ.tsx +++ b/template/app/src/landing-page/components/FAQ.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + interface FAQ { id: number; question: string; @@ -6,10 +8,12 @@ interface FAQ { }; export default function FAQ({ faqs }: { faqs: FAQ[] }) { + const { t } = useTranslation(); + return (

- Frequently asked questions + {t('faqs.title')}

{faqs.map((faq) => ( @@ -21,7 +25,7 @@ export default function FAQ({ faqs }: { faqs: FAQ[] }) {

{faq.answer}

{faq.href && ( - Learn more → + {t('common.learnMore')} → )} diff --git a/template/app/src/landing-page/components/Features.tsx b/template/app/src/landing-page/components/Features.tsx index a81b1319..50af73f6 100644 --- a/template/app/src/landing-page/components/Features.tsx +++ b/template/app/src/landing-page/components/Features.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + interface Feature { name: string; description: string; @@ -6,15 +8,16 @@ interface Feature { }; export default function Features({ features }: { features: Feature[] }) { + const { t } = useTranslation(); + return (

- The Best Features + {t('features.title')}

- Don't work harder. -
Work smarter. + {t('features.subtitle')}

diff --git a/template/app/src/landing-page/components/Hero.tsx b/template/app/src/landing-page/components/Hero.tsx index c776d1ef..b7193dd8 100644 --- a/template/app/src/landing-page/components/Hero.tsx +++ b/template/app/src/landing-page/components/Hero.tsx @@ -1,7 +1,10 @@ +import { useTranslation } from 'react-i18next'; import openSaasBannerWebp from '../../client/static/open-saas-banner.webp'; import { DocsUrl } from '../../shared/common'; export default function Hero() { + const { t } = useTranslation(); + return (
@@ -10,17 +13,17 @@ export default function Hero() {

- Some cool words about your product + {t('hero.title')}

- With some more exciting words about your product! + {t('hero.subtitle')}

diff --git a/template/app/src/landing-page/contentSections.ts b/template/app/src/landing-page/contentSections.ts index a45ade21..6c3229ce 100644 --- a/template/app/src/landing-page/contentSections.ts +++ b/template/app/src/landing-page/contentSections.ts @@ -3,13 +3,27 @@ import { routes } from 'wasp/client/router'; import { DocsUrl, BlogUrl } from '../shared/common'; import daBoiAvatar from '../client/static/da-boi.webp'; import avatarPlaceholder from '../client/static/avatar-placeholder.webp'; +import { useTranslation } from 'react-i18next'; +// Navigation Items export const landingPageNavigationItems: NavigationItem[] = [ { name: 'Features', to: '#features' }, { name: 'Pricing', to: routes.PricingPageRoute.to }, { name: 'Documentation', to: DocsUrl }, { name: 'Blog', to: BlogUrl }, ]; + +export const useLandingPageNavigationItems = (): NavigationItem[] => { + const { t } = useTranslation(); + return [ + { name: t('navigation.features'), to: '#features' }, + { name: t('navigation.pricing'), to: routes.PricingPageRoute.to }, + { name: t('navigation.documentation'), to: DocsUrl }, + { name: t('navigation.blog'), to: BlogUrl }, + ]; +}; + +// Features export const features = [ { name: 'Cool Feature #1', @@ -36,6 +50,38 @@ export const features = [ href: DocsUrl, }, ]; + +export const useFeatures = () => { + const { t } = useTranslation(); + return [ + { + name: t('features.feature1.name'), + description: t('features.feature1.description'), + icon: '🤝', + href: DocsUrl, + }, + { + name: t('features.feature2.name'), + description: t('features.feature2.description'), + icon: '🔐', + href: DocsUrl, + }, + { + name: t('features.feature3.name'), + description: t('features.feature3.description'), + icon: '🥞', + href: DocsUrl, + }, + { + name: t('features.feature4.name'), + description: t('features.feature4.description'), + icon: '💸', + href: DocsUrl, + }, + ]; +}; + +// Testimonials export const testimonials = [ { name: 'Da Boi', @@ -60,14 +106,57 @@ export const testimonials = [ }, ]; +export const useTestimonials = () => { + const { t } = useTranslation(); + return [ + { + name: t('testimonials.daBoi.name'), + role: t('testimonials.daBoi.role'), + avatarSrc: daBoiAvatar, + socialUrl: 'https://twitter.com/wasplang', + quote: t('testimonials.daBoi.quote'), + }, + { + name: t('testimonials.mrFoobar.name'), + role: t('testimonials.mrFoobar.role'), + avatarSrc: avatarPlaceholder, + socialUrl: '', + quote: t('testimonials.mrFoobar.quote'), + }, + { + name: t('testimonials.jamie.name'), + role: t('testimonials.jamie.role'), + avatarSrc: avatarPlaceholder, + socialUrl: '#', + quote: t('testimonials.jamie.quote'), + }, + ]; +}; + +// Static FAQs for non-translated usage export const faqs = [ { id: 1, - question: 'Whats the meaning of life?', - answer: '42.', + question: "What's the meaning of life?", + answer: "42.", href: 'https://en.wikipedia.org/wiki/42_(number)', }, ]; + +// Hook for translated FAQs +export const useFaqs = () => { + const { t } = useTranslation(); + return [ + { + id: 1, + question: t('faqs.question1.question'), + answer: t('faqs.question1.answer'), + href: 'https://en.wikipedia.org/wiki/42_(number)', + }, + ]; +}; + +// Static footer navigation for non-translated usage export const footerNavigation = { app: [ { name: 'Documentation', href: DocsUrl }, @@ -76,6 +165,22 @@ export const footerNavigation = { company: [ { name: 'About', href: 'https://wasp-lang.dev' }, { name: 'Privacy', href: '#' }, - { name: 'Terms of Service', href: '#' }, + { name: 'Terms', href: '#' }, ], }; + +// Hook for translated footer navigation +export const useFooterNavigation = () => { + const { t } = useTranslation(); + return { + app: [ + { name: t('footer.app.documentation'), href: DocsUrl }, + { name: t('footer.app.blog'), href: BlogUrl }, + ], + company: [ + { name: t('footer.company.about'), href: 'https://wasp-lang.dev' }, + { name: t('footer.company.privacy'), href: '#' }, + { name: t('footer.company.terms'), href: '#' }, + ], + }; +}; diff --git a/template/app/src/payment/PricingPage.tsx b/template/app/src/payment/PricingPage.tsx index 8afc4f86..0e955260 100644 --- a/template/app/src/payment/PricingPage.tsx +++ b/template/app/src/payment/PricingPage.tsx @@ -5,6 +5,7 @@ import { AiFillCheckCircle } from 'react-icons/ai'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { cn } from '../client/cn'; +import { useTranslation } from 'react-i18next'; const bestDealPaymentPlanId: PaymentPlanId = PaymentPlanId.Pro; @@ -15,27 +16,6 @@ interface PaymentPlanCard { features: string[]; } -export const paymentPlanCards: Record = { - [PaymentPlanId.Hobby]: { - name: prettyPaymentPlanName(PaymentPlanId.Hobby), - price: '$9.99', - description: 'All you need to get started', - features: ['Limited monthly usage', 'Basic support'], - }, - [PaymentPlanId.Pro]: { - name: prettyPaymentPlanName(PaymentPlanId.Pro), - price: '$19.99', - description: 'Our most popular plan', - features: ['Unlimited monthly usage', 'Priority customer support'], - }, - [PaymentPlanId.Credits10]: { - name: prettyPaymentPlanName(PaymentPlanId.Credits10), - price: '$9.99', - description: 'One-time purchase of 10 credits for your account', - features: ['Use credits for e.g. OpenAI API calls', 'No expiration date'], - }, -}; - const PricingPage = () => { const [isPaymentLoading, setIsPaymentLoading] = useState(false); @@ -50,6 +30,29 @@ const PricingPage = () => { const navigate = useNavigate(); + const { t } = useTranslation(); + + const paymentPlanCards: Record = { + [PaymentPlanId.Hobby]: { + name: prettyPaymentPlanName(PaymentPlanId.Hobby), + price: t('pricing.plans.hobby.price'), + description: t('pricing.plans.hobby.description'), + features: t('pricing.plans.hobby.features', { returnObjects: true }) as string[], + }, + [PaymentPlanId.Pro]: { + name: prettyPaymentPlanName(PaymentPlanId.Pro), + price: t('pricing.plans.pro.price'), + description: t('pricing.plans.pro.description'), + features: t('pricing.plans.pro.features', { returnObjects: true }) as string[], + }, + [PaymentPlanId.Credits10]: { + name: prettyPaymentPlanName(PaymentPlanId.Credits10), + price: t('pricing.plans.credits10.price'), + description: t('pricing.plans.credits10.description'), + features: t('pricing.plans.credits10.features', { returnObjects: true }) as string[], + }, + }; + async function handleBuyNowClick(paymentPlanId: PaymentPlanId) { if (!user) { navigate('/login'); @@ -93,13 +96,12 @@ const PricingPage = () => {

- Pick your pricing + {t('pricing.title')}

- Choose between Stripe and LemonSqueezy as your payment provider. Just add your Product IDs! Try it - out below with test credit card number
- 4242 4242 4242 4242 4242 + {t('pricing.subtitle')}
+ {t('pricing.testCard')}

{Object.values(PaymentPlanId).map((planId) => ( @@ -140,7 +142,7 @@ const PricingPage = () => { {paymentPlanCards[planId].price} - {paymentPlans[planId].effect.kind === 'subscription' && '/month'} + {paymentPlans[planId].effect.kind === 'subscription' && t('pricing.perMonth')}

    @@ -167,7 +169,7 @@ const PricingPage = () => { } )} > - Manage Subscription + {t('pricing.buttons.manageSubscription')} ) : ( )}
diff --git a/template/app/src/user/AccountPage.tsx b/template/app/src/user/AccountPage.tsx index 71b9a8e1..75617ec6 100644 --- a/template/app/src/user/AccountPage.tsx +++ b/template/app/src/user/AccountPage.tsx @@ -3,30 +3,33 @@ import { type SubscriptionStatus, prettyPaymentPlanName, parsePaymentPlanId } fr import { getCustomerPortalUrl, useQuery } from 'wasp/client/operations'; import { Link as WaspRouterLink, routes } from 'wasp/client/router'; import { logout } from 'wasp/client/auth'; +import { useTranslation } from 'react-i18next'; export default function AccountPage({ user }: { user: User }) { + const { t } = useTranslation(); + return (
-

Account Information

+

{t('account.title')}

{!!user.email && (
-
Email address
+
{t('account.email')}
{user.email}
)} {!!user.username && (
-
Username
+
{t('account.username')}
{user.username}
)}
-
Your Plan
+
{t('account.plan')}
-
About
-
I'm a cool customer.
+
{t('account.about')}
+
{t('account.aboutText')}
@@ -46,7 +49,7 @@ export default function AccountPage({ user }: { user: User }) { onClick={logout} className='inline-flex justify-center mx-8 py-2 px-4 border border-transparent shadow-md text-sm font-medium rounded-md text-white bg-yellow-500 hover:bg-yellow-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500' > - logout + {t('account.logout')}
@@ -61,10 +64,14 @@ type UserCurrentPaymentPlanProps = { }; function UserCurrentPaymentPlan({ subscriptionPlan, subscriptionStatus, datePaid, credits }: UserCurrentPaymentPlanProps) { + const { t } = useTranslation(); + if (subscriptionStatus && subscriptionPlan && datePaid) { return ( <> -
{getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid })}
+
+ {getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid })} +
{subscriptionStatus !== 'deleted' ? : } ); @@ -72,16 +79,23 @@ function UserCurrentPaymentPlan({ subscriptionPlan, subscriptionStatus, datePaid return ( <> -
Credits remaining: {credits}
+
+ {t('account.subscription.credits', { credits })} +
); } function getUserSubscriptionStatusDescription({ subscriptionPlan, subscriptionStatus, datePaid }: { subscriptionPlan: string; subscriptionStatus: SubscriptionStatus; datePaid: Date }) { + const { t } = useTranslation(); const planName = prettyPaymentPlanName(parsePaymentPlanId(subscriptionPlan)); const endOfBillingPeriod = prettyPrintEndOfBillingPeriod(datePaid); - return prettyPrintStatus(planName, subscriptionStatus, endOfBillingPeriod); + + return t(`account.subscription.status.${subscriptionStatus}`, { + planName, + date: endOfBillingPeriod + }); } function prettyPrintStatus(planName: string, subscriptionStatus: SubscriptionStatus, endOfBillingPeriod: string): string { @@ -105,34 +119,40 @@ function prettyPrintEndOfBillingPeriod(date: Date) { } function BuyMoreButton() { + const { t } = useTranslation(); return (
- Buy More/Upgrade + {t('account.subscription.buyMore')}
); } function CustomerPortalButton() { + const { t } = useTranslation(); const { data: customerPortalUrl, isLoading: isCustomerPortalUrlLoading, error: customerPortalUrlError } = useQuery(getCustomerPortalUrl); const handleClick = () => { if (customerPortalUrlError) { - console.error('Error fetching customer portal url'); + console.error(t('account.subscription.errors.portalUrl')); } if (customerPortalUrl) { window.open(customerPortalUrl, '_blank'); } else { - console.error('Customer portal URL is not available'); + console.error(t('account.subscription.errors.portalNotAvailable')); } }; return (
-
); diff --git a/template/app/src/user/UserMenuItems.tsx b/template/app/src/user/UserMenuItems.tsx index f719e0c4..0d70b8bd 100644 --- a/template/app/src/user/UserMenuItems.tsx +++ b/template/app/src/user/UserMenuItems.tsx @@ -4,8 +4,10 @@ import { logout } from 'wasp/client/auth'; import { MdOutlineSpaceDashboard } from 'react-icons/md'; import { TfiDashboard } from 'react-icons/tfi'; import { cn } from '../client/cn'; +import { useTranslation } from 'react-i18next'; export const UserMenuItems = ({ user, setMobileMenuOpen }: { user?: Partial; setMobileMenuOpen?: any }) => { + const { t } = useTranslation(); const path = window.location.pathname; const landingPagePath = routes.LandingPageRoute.to; const adminDashboardPath = routes.AdminRoute.to; @@ -29,7 +31,7 @@ export const UserMenuItems = ({ user, setMobileMenuOpen }: { user?: Partial - AI Scheduler (Demo App) + {t('userMenu.aiScheduler')} ) : null} @@ -56,7 +58,7 @@ export const UserMenuItems = ({ user, setMobileMenuOpen }: { user?: Partial - Account Settings + {t('userMenu.accountSettings')} @@ -74,7 +76,7 @@ export const UserMenuItems = ({ user, setMobileMenuOpen }: { user?: Partial - Admin Dashboard + {t('userMenu.adminDashboard')} @@ -106,7 +108,7 @@ export const UserMenuItems = ({ user, setMobileMenuOpen }: { user?: Partial - Log Out + {t('userMenu.logout')} );