diff --git a/messages/en-US.json b/messages/en-US.json new file mode 100644 index 0000000..4b3505a --- /dev/null +++ b/messages/en-US.json @@ -0,0 +1,374 @@ +{ + "Header": { + "groups": "Groups" + }, + "Footer": { + "madeIn": "Made in Montréal, Québec 🇨🇦", + "builtBy": "Built by Sebastien Castiel and contributors" + }, + "Expenses": { + "title": "Expenses", + "description": "Here are the expenses that you created for your group.", + "create": "Create expense", + "createFirst": "Create the first one", + "noExpenses": "Your group doesn’t contain any expense yet.", + "exportJson": "Export to JSON", + "searchPlaceholder": "Search for an expense…", + "ActiveUserModal": { + "title": "Who are you?", + "description": "Tell us which participant you are to let us customize how the information is displayed.", + "nobody": "I don’t want to select anyone", + "save": "Save changes", + "footer": "This setting can be changed later in the group settings." + }, + "Groups": { + "upcoming": "Upcoming", + "thisWeek": "This week", + "earlierThisMonth": "Earlier this month", + "lastMonth": "Last month", + "earlierThisYear": "Earlier this year", + "lastYera": "Last year", + "older": "Older" + } + }, + "ExpenseCard": { + "paidBy": "Paid by {paidBy} for ", + "receivedBy": "Received by {paidBy} for ", + "yourBalance": "Your balance:" + }, + "Groups": { + "myGroups": "My groups", + "create": "Create", + "loadingRecent": "Loading recent groups…", + "NoRecent": { + "description": "You have not visited any group recently.", + "create": "Create one", + "orAsk": "or ask a friend to send you the link to an existing one." + }, + "recent": "Recent groups", + "starred": "Starred groups", + "archived": "Archived groups", + "archive": "Archive group", + "unarchive": "Unarchive group", + "removeRecent": "Remove from recent groups", + "RecentRemovedToast": { + "title": "Group has been removed", + "description": "The group was removed from your recent groups list.", + "undoAlt": "Undo group removal", + "undo": "Undo" + }, + "AddByURL": { + "button": "Add by URL", + "title": "Add a group by URL", + "description": "If a group was shared with you, you can paste its URL here to add it to your list.", + "error": "Oops, we are not able to find the group from the URL you provided…" + }, + "NotFound": { + "text": "This group does not exist.", + "link": "Go to recently visited groups" + } + }, + "GroupForm": { + "title": "Group information", + "NameField": { + "label": "Group name", + "placeholder": "Summer vacations", + "description": "Enter a name for your group." + }, + "CurrencyField": { + "label": "Currency symbol", + "placeholder": "$, €, £…", + "description": "We’ll use it to display amounts." + }, + "Participants": { + "title": "Participants", + "description": "Enter the name for each participant.", + "protectedParticipant": "This participant is part of expenses, and can not be removed.", + "new": "New", + "add": "Add participant", + "John": "John", + "Jane": "Jane", + "Jack": "Jack" + }, + "Settings": { + "title": "Local settings", + "description": "These settings are set per-device, and are used to customize your experience.", + "ActiveUserField": { + "label": "Active user", + "placeholder": "Select a participant", + "none": "None", + "description": "User used as default for paying expenses." + }, + "save": "Save", + "saving": "Saving…", + "create": "Create", + "creating": "Creating…", + "cancel": "Cancel" + } + }, + "ExpenseForm": { + "Income": { + "create": "Create income", + "edit": "Edit income", + "TitleField": { + "label": "Income title", + "placeholder": "Monday evening restaurant", + "description": "Enter a description for the income." + }, + "DateField": { + "label": "Income date", + "description": "Enter the date the income was received." + }, + "categoryFieldDescription": "Select the income category.", + "paidByField": { + "label": "Received by", + "description": "Select the participant who received the income." + }, + "paidFor": { + "title": "Received for", + "description": "Select who the income was received for." + }, + "splitModeDescription": "Select how to split the income.", + "attachDescription": "See and attach receipts to the income." + }, + "Expense": { + "create": "Create expense", + "edit": "Edit expense", + "TitleField": { + "label": "Expense title", + "placeholder": "Monday evening restaurant", + "description": "Enter a description for the expense." + }, + "DateField": { + "label": "Expense date", + "description": "Enter the date the expense was paid." + }, + "categoryFieldDescription": "Select the expense category.", + "paidByField": { + "label": "Paid by", + "description": "Select the participant who paid the expense." + }, + "paidFor": { + "title": "Paid for", + "description": "Select who the expense was paid for." + }, + "splitModeDescription": "Select how to split the expense.", + "attachDescription": "See and attach receipts to the expense." + }, + "amountField": { + "label": "Amount" + }, + "isReimbursementField": { + "label": "This is a reimbursement" + }, + "categoryField": { + "label": "Category" + }, + "notesField": { + "label": "Notes" + }, + "selectNone": "Select none", + "selectAll": "Select all", + "shares": "share(s)", + "advancedOptions": "Advanced splitting options…", + "SplitModeField": { + "label": "Split mode", + "evenly": "Evenly", + "byShares": "Unevenly – By shares", + "byPercentage": "Unevenly – By percentage", + "byAmount": "Unevenly – By amount", + "saveAsDefault": "Save as default splitting options" + }, + "DeletePopup": { + "label": "Delete", + "title": "Delete this expense?", + "description": "Do you really want to delete this expense? This action is irreversible.", + "yes": "Yes", + "cancel": "Cancel" + }, + "attachDocuments": "Attach documents", + "create": "Create", + "creating": "Creating…", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel" + }, + "ExpenseDocumentsInput": { + "TooBigToast": { + "title": "The file is too big", + "description": "The maximum file size you can upload is {maxSize}. Yours is ${size}." + }, + "ErrorToast": { + "title": "Error while uploading document", + "description": "Something wrong happened when uploading the document. Please retry later or select a different file.", + "retry": "Retry" + } + }, + "CreateFromReceipt": { + "Dialog": { + "triggerTitle": "Create expense from receipt", + "title": "Create from receipt", + "description": "Extract the expense information from a receipt photo.", + "body": "Upload the photo of a receipt, and we’ll scan it to extract the expense information if we can.", + "selectImage": "Select image…", + "titleLabel": "Title:", + "categoryLabel": "Category:", + "amountLabel": "Amount:", + "dateLabel": "Date:", + "editNext": "You’ll be able to edit the expense information next.", + "continue": "Continue" + }, + "unknown": "Unknown", + "TooBigToast": { + "title": "The file is too big", + "description": "The maximum file size you can upload is {maxSize}. Yours is ${size}." + }, + "ErrorToast": { + "title": "Error while uploading document", + "description": "Something wrong happened when uploading the document. Please retry later or select a different file.", + "retry": "Retry" + } + }, + "Balances": { + "title": "Balances", + "description": "This is the amount that each participant paid or was paid for.", + "Reimbursements": { + "title": "Suggested reimbursements", + "description": "Here are suggestions for optimized reimbursements between participants.", + "noImbursements": "It looks like your group doesn’t need any reimbursement 😁", + "owes": "{from} owes {to}", + "markAsPaid": "Mark as paid" + } + }, + "Stats": { + "title": "Stats", + "Totals": { + "title": "Totals", + "description": "Spending summary of the entire group.", + "groupSpendings": "Total group spendings", + "groupEarnings": "Total group earnings", + "yourSpendings": "Your total spendings", + "yourEarnings": "Your total earnings", + "yourShare": "Your total share" + } + }, + "Activity": { + "title": "Activity", + "description": "Overview of all activity in this group.", + "noActivity": "There is not yet any activity in your group.", + "someone": "Someone", + "settingsModified": "Group settings were modified by {participant}.", + "expenseCreated": "Expense {expense} created by {participant}.", + "expenseUpdated": "Expense {expense} updated by {participant}.", + "expenseDeleted": "Expense {expense} deleted by {participant}.", + "Groups": { + "today": "Today", + "yesterday": "Yesterday", + "earlierThisWeek": "Earlier this week", + "lastWeek": "Last week", + "earlierThisMonth": "Earlier this month", + "lastMonth": "Last month", + "earlierThisYear": "Earlier this year", + "lastYear": "Last year", + "older": "Older" + } + }, + "Settings": { + "title": "Settings" + }, + "Locale": { + "en-US": "English (US)", + "fi": "Suomi" + }, + "Share": { + "title": "Share", + "description": "For other participants to see the group and add expenses, share its URL with them.", + "warning": "Warning!", + "warningHelp": "Every person with the group URL will be able to see and edit expenses. Share with caution!" + }, + "SchemaErrors": { + "min1": "Enter at least one character.", + "min2": "Enter at least two characters.", + "max5": "Enter at most five characters.", + "max50": "Enter at most 50 characters.", + "duplicateParticipantName": "Another participant already has this name.", + "titleRequired": "Please enter a title.", + "invalidNumber": "Invalid number.", + "amountRequired": "You must enter an amount.", + "amountNotZero": "The amount must not be zero.", + "amountTenMillion": "The amount must be lower than 10,000,000.", + "paidByRequired": "You must select a participant.", + "paidForMin1": "The expense must be paid for at least one participant.", + "noZeroShares": "All shares must be higher than 0.", + "amountSum": "Sum of amounts must equal the expense amount.", + "percentageSum": "Sum of percentages must equal 100." + }, + "Categories": { + "search": "Search category...", + "noCategory": "No category found.", + "Uncategorized": { + "heading": "Uncategorized", + "General": "General", + "Payment": "Payment" + }, + "Entertainment": { + "heading": "Entertainment", + "Entertainment": "Entertainment", + "Games": "Games", + "Movies": "Movies", + "Music": "Music", + "Sports": "Sports" + }, + "Food and Drink": { + "heading": "Food and Drink", + "Food and Drink": "Food and Drink", + "Dining Out": "Dining Out", + "Groceries": "Groceries", + "Liquor": "Liquor" + }, + "Home": { + "heading": "Home", + "Home": "Home", + "Electronics": "Electronics", + "Furniture": "Furniture", + "Household Supplies": "Household Supplies", + "Maintenance": "Maintenance", + "Mortgage": "Mortgage", + "Pets": "Pets", + "Rent": "Rent", + "Services": "Services" + }, + "Life": { + "heading": "Life", + "Childcare": "Childcare", + "Clothing": "Clothing", + "Education": "Education", + "Gifts": "Gifts", + "Insurance": "Insurance", + "Medical Expenses": "Medical Expenses", + "Taxes": "Taxes" + }, + "Transportation": { + "heading": "Transportation", + "Transportation": "Transportation", + "Bicycle": "Bicycle", + "Bus/Train": "Bus/Train", + "Car": "Car", + "Gas/Fuel": "Gas/Fuel", + "Hotel": "Hotel", + "Parking": "Parking", + "Plane": "Plane", + "Taxi": "Taxi" + }, + "Utilities": { + "heading": "Utilities", + "Utilities": "Utilities", + "Cleaning": "Cleaning", + "Electricity": "Electricity", + "Heat/Gas": "Heat/Gas", + "Trash": "Trash", + "TV/Phone/Internet": "TV/Phone/Internet", + "Water": "Water" + } + } +} diff --git a/messages/fi.json b/messages/fi.json new file mode 100644 index 0000000..6df9056 --- /dev/null +++ b/messages/fi.json @@ -0,0 +1,374 @@ +{ + "Header": { + "groups": "Ryhmät" + }, + "Footer": { + "madeIn": "Made in Montréal, Québec 🇨🇦", + "builtBy": "Tekijät: Sebastien Castiel ja muut osallistujat" + }, + "Expenses": { + "title": "Kulut", + "description": "Tässä ovat ryhmässä luodut kulut.", + "create": "Lisää kulu", + "createFirst": "Lisää ensimmäinen kulu", + "noExpenses": "Ryhmälläsi ei ole vielä yhtään kulua.", + "exportJson": "Vie JSON-tiedostoon", + "searchPlaceholder": "Etsi kulua…", + "ActiveUserModal": { + "title": "Kuka olet?", + "description": "Valitse kuka osallistujista olet, jotta tiedot näkyvät oikein.", + "nobody": "En halua valita ketään", + "save": "Tallenna muutokset", + "footer": "Tämän asetuksen voi vaihtaa myöhemmin ryhmän asetuksista." + }, + "Groups": { + "upcoming": "Tulevat", + "thisWeek": "Tällä viikolla", + "earlierThisMonth": "Aikaisemmin tässä kuussa", + "lastMonth": "Viime kuussa", + "earlierThisYear": "Aikaisemmin tänä vuonna", + "lastYear": "Viime vuonna", + "older": "Vanhemmat" + } + }, + "ExpenseCard": { + "paidBy": "{paidBy} maksoi {forCount, plural, =1 {henkilön} other {henkilöiden}} puolesta", + "receivedBy": "{paidBy} sai rahaa {forCount, plural, =1 {henkilön} other {henkilöiden}} puolesta", + "yourBalance": "Saldosi:" + }, + "Groups": { + "myGroups": "Omat ryhmät", + "create": "Luo ryhmä", + "loadingRecent": "Ladataan äskettäisiä ryhmiä…", + "NoRecent": { + "description": "Et ole ollut missään ryhmässä äskettäin.", + "create": "Luo uusi ryhmä", + "orAsk": "tai pyydä ystävää lähettämään linkki olemassaolevaan ryhmään." + }, + "recent": "Äskettäiset", + "starred": "Suosikit", + "archived": "Arkistoidut", + "archive": "Arkistoi ryhmä", + "unarchive": "Palauta ryhmä arkistosta", + "removeRecent": "Poista äskettäisistä", + "RecentRemovedToast": { + "title": "Ryhmä poistettu", + "description": "Ryhmä poistettu äskettäisten listaltasi.", + "undoAlt": "Peruuta ryhmän poisto", + "undo": "Peruuta" + }, + "AddByURL": { + "button": "Lisää URLilla", + "title": "Lisää ryhmä URL-osoitteella", + "description": "Jos ryhmä on jaettu sinulle, voit lisätä sen listaasi liittämällä URL-osoitteen tähän.", + "error": "Hups, emme löytäneet ryhmää antamastasi URL-osoitteesta…" + }, + "NotFound": { + "text": "Tätä ryhmää ei löydy.", + "link": "Siirry äskettäisiin ryhmiin" + } + }, + "GroupForm": { + "title": "Ryhmän tiedot", + "NameField": { + "label": "Ryhmän nimi", + "placeholder": "Kesälomareissu", + "description": "Syötä ryhmäsi nimi." + }, + "CurrencyField": { + "label": "Valuuttamerkki", + "placeholder": "$, €, £…", + "description": "Näytetään rahasummien yhteydessä." + }, + "Participants": { + "title": "Osallistujat", + "description": "Syötä jokaisen osallistujan nimi.", + "protectedParticipant": "Tätä osallistujaa ei voida poistaa, koska hän osallistuu kuluihin.", + "add": "Lisää osallistuja", + "new": "Uusi", + "John": "Antti", + "Jane": "Laura", + "Jack": "Jussi" + }, + "Settings": { + "title": "Paikalliset asetukset", + "description": "Nämä asetukset ovat laitekohtaisia. Voit muokata niillä käytettävyyttä.", + "ActiveUserField": { + "label": "Aktiivinen käyttäjä", + "placeholder": "Valitse osallistuja", + "none": "Ei kukaan", + "description": "Käytetään kulujen oletusmaksajana." + }, + "save": "Tallenna", + "saving": "Tallennetaan…", + "create": "Luo ryhmä", + "creating": "Luodaan…", + "cancel": "Peruuta" + } + }, + "ExpenseForm": { + "Income": { + "create": "Lisää tulo", + "edit": "Muokkaa tuloa", + "TitleField": { + "label": "Otsikko", + "placeholder": "Maanantain ravintola", + "description": "Anna lyhyt kuvaus tulolle." + }, + "DateField": { + "label": "Päivä", + "description": "Valitse päivä jolloin tulo saatiin." + }, + "categoryFieldDescription": "Valitse tulokategoria.", + "paidByField": { + "label": "Vastaanottaja", + "description": "Valitse kuka vastaanotti tulon." + }, + "paidFor": { + "title": "Tulon jakaminen", + "description": "Valitse kenelle tulo jaetaan." + }, + "splitModeDescription": "Valitse miten tulo jaetaan osallistujien kesken.", + "attachDescription": "Katso ja liitä tuloon liittyviä kuitteja." + }, + "Expense": { + "create": "Lisää kulu", + "edit": "Muokkaa kulua", + "TitleField": { + "label": "Otsikko", + "placeholder": "Maanantain ravintola", + "description": "Anna lyhyt kuvaus kululle." + }, + "DateField": { + "label": "Päivä", + "description": "Valitse päivä jolloin kulu maksettiin." + }, + "categoryFieldDescription": "Valitse kulukategoria.", + "paidByField": { + "label": "Maksaja", + "description": "Valitse kuka maksoi kulun." + }, + "paidFor": { + "title": "Kulun jakaminen", + "description": "Valitse ketkä osallistuvat kuluun." + }, + "splitModeDescription": "Valitse miten kulu jaetaan osallistujien kesken.", + "attachDescription": "Katso ja liitä kuluun liittyviä kuitteja." + }, + "amountField": { + "label": "Summa" + }, + "isReimbursementField": { + "label": "Tämä on velanmaksu" + }, + "categoryField": { + "label": "Kategoria" + }, + "notesField": { + "label": "Muistiinpanot" + }, + "selectNone": "Tyhjennä valinnat", + "selectAll": "Valitse kaikki", + "shares": "osuutta", + "advancedOptions": "Lisäasetuksia jakamiseen…", + "SplitModeField": { + "label": "Jakamistapa", + "evenly": "Tasan", + "byShares": "Epätasan – osuuksien mukaan", + "byPercentage": "Epätasan – prosenttien mukaan", + "byAmount": "Epätasan – summan mukaan", + "saveAsDefault": "Tallenna oletustavaksi" + }, + "DeletePopup": { + "label": "Poista", + "title": "Poistetaanko tämä kulu?", + "description": "Haluatko varmasti poistaa tämän kulun? Poistoa ei voi peruuttaa.", + "yes": "Kyllä", + "cancel": "Peruuta" + }, + "attachDocuments": "Liitä dokumenttejä", + "create": "Lisää kulu", + "creating": "Luodaan kulua…", + "save": "Tallenna", + "saving": "Tallennetaan…", + "cancel": "Peruuta" + }, + "ExpenseDocumentsInput": { + "TooBigToast": { + "title": "Tiedosto on liian suuri", + "description": "Maksimikoko ladattavalle tiedostolle on {maxSize}. Tiedostosi on ${size}." + }, + "ErrorToast": { + "title": "Virhe tiedostoa ladattaessa", + "description": "Jokin meni vikaan dokumentin lataamisessa. Yritä myöhemmin uudelleen tai valitse toinen tiedosto.", + "retry": "Yritä uudelleen" + } + }, + "CreateFromReceipt": { + "Dialog": { + "triggerTitle": "Luo kulu kuitista", + "title": "Luo kuitista", + "description": "Lue kuitin valokuvasta kulun tiedot.", + "body": "Lataa kuitista valokuva. Siitä skannataan tiedot kulua varten.", + "selectImage": "Valitse kuva…", + "titleLabel": "Otsikko:", + "categoryLabel": "Kategoria:", + "amountLabel": "Summa:", + "dateLabel": "Päivä:", + "editNext": "Voit muokata kulun tietoja seuraavaksi.", + "continue": "Jatka" + }, + "unknown": "Unknown", + "TooBigToast": { + "title": "The file is too big", + "description": "The maximum file size you can upload is {maxSize}. Yours is ${size}." + }, + "ErrorToast": { + "title": "Error while uploading document", + "description": "Something wrong happened when uploading the document. Please retry later or select a different file.", + "retry": "Retry" + } + }, + "Balances": { + "title": "Saldo", + "description": "Osallistujien saatavat tai velat.", + "Reimbursements": { + "title": "Maksuehdotus", + "description": "Optimoitu ehdotus kuka maksaa kenellekin.", + "noImbursements": "Näyttää siltä, että kaikki ovat sujut 😁", + "owes": "{from} maksaa henkilölle {to}", + "markAsPaid": "Merkitse maksetuksi" + } + }, + "Stats": { + "title": "Tilastot", + "Totals": { + "title": "Yhteenveto", + "description": "Koko ryhmän kulut.", + "groupSpendings": "Koko ryhmän kulutus", + "groupEarnings": "Koko ryhmän saatavat", + "yourSpendings": "Kulutuksesi", + "yourEarnings": "Saatavasi", + "yourShare": "Osuutesi" + } + }, + "Activity": { + "title": "Tapahtumat", + "description": "Yleisnäkymä ryhmän kaikista tapahtumista.", + "noActivity": "Ryhmässäsi ei ole vielä tapahtumia.", + "someone": "Tuntematon", + "settingsModified": "{participant} muokkasi ryhmän asetuksia.", + "expenseCreated": "{participant} lisäsi kulun {expense}.", + "expenseUpdated": "{participant} muokkasi kulua {expense}.", + "expenseDeleted": "{participant} poisti kulun {expense}.", + "Groups": { + "today": "Tänään", + "yesterday": "Eilen", + "earlierThisWeek": "Tällä viikolla", + "lastWeek": "Viime viikolla", + "earlierThisMonth": "Tässä kuussa", + "lastMonth": "Viime kuussa", + "earlierThisYear": "Tänä vuonna", + "lastYear": "Viime vuonna", + "older": "Vanhemmat" + } + }, + "Settings": { + "title": "Asetukset" + }, + "Locale": { + "en-US": "English (US)", + "fi": "Suomi" + }, + "Share": { + "title": "Jaa", + "description": "Jaa ryhmän URL muille jäsenille, jotta he voivat nähdä sen ja lisätä kuluja.", + "warning": "Varoitus!", + "warningHelp": "Tällä URLilla kuka tahansa pääsee näkemään ja muokkaamaan kuluja. Jaa harkiten!" + }, + "SchemaErrors": { + "min1": "Syötä vähintään yksi merkki.", + "min2": "Syötä vähintään kaksi merkkiä.", + "max5": "Syötä enintään viisi merkkiä.", + "max50": "Syötä enintään 50 merkkiä.", + "duplicateParticipantName": "Tämä nimi on jo toisella osallistujalla.", + "titleRequired": "Otsikko puuttuu.", + "invalidNumber": "Epäkelpo numero.", + "amountRequired": "Summa puuttuu.", + "amountNotZero": "Summa ei voi olla nolla.", + "amountTenMillion": "Summan pitää olla pienempi kuin 10 000 000.", + "paidByRequired": "Osallistuja puuttuu.", + "paidForMin1": "Valitse vähintään yksi osallistuja.", + "noZeroShares": "Jokaisen osuuden täytyy olla suurempi kuin 0.", + "amountSum": "Osuuksien summan täytyy vastata kulun summaa.", + "percentageSum": "Prosenttiosuuksien summan täytyy olla 100." + }, + "Categories": { + "search": "Etsi kategoriaa...", + "noCategory": "Kategoriaa ei löydy.", + "Uncategorized": { + "heading": "Yleiset", + "General": "Yleinen", + "Payment": "Maksu" + }, + "Entertainment": { + "heading": "Viihde", + "Entertainment": "Viihde", + "Games": "Pelit", + "Movies": "Elokuvat", + "Music": "Musiikki", + "Sports": "Urheilu" + }, + "Food and Drink": { + "heading": "Ruoka ja juoma", + "Food and Drink": "Ruoka ja juoma", + "Dining Out": "Ulkona syöminen", + "Groceries": "Marketti", + "Liquor": "Alkoholi" + }, + "Home": { + "heading": "Koti", + "Home": "Koti", + "Electronics": "Elektroniikka", + "Furniture": "Huonekalut", + "Household Supplies": "Taloustavarat", + "Maintenance": "Huolto", + "Mortgage": "Laina", + "Pets": "Lemmikit", + "Rent": "Vuokra", + "Services": "Palvelut" + }, + "Life": { + "heading": "Elämä", + "Childcare": "Lastenhoito", + "Clothing": "Vaatteet", + "Education": "Opiskelu", + "Gifts": "Lahjat", + "Insurance": "Vakuutukset", + "Medical Expenses": "Terveydenhoito", + "Taxes": "Verot" + }, + "Transportation": { + "heading": "Liikenne", + "Transportation": "Liikenne", + "Bicycle": "Polkupyörä", + "Bus/Train": "Bussi/juna", + "Car": "Auto", + "Gas/Fuel": "Polttoaine", + "Hotel": "Hotelli", + "Parking": "Pysäköinti", + "Plane": "Lentäminen", + "Taxi": "Taksi" + }, + "Utilities": { + "heading": "Sekalaiset", + "Utilities": "Sekalaiset", + "Cleaning": "Siivous", + "Electricity": "Sähkö", + "Heat/Gas": "Lämmitys", + "Trash": "Jätehuolto", + "TV/Phone/Internet": "TV/Puhelin/Internet", + "Water": "Vesi" + } + } +} diff --git a/next.config.js b/next.config.mjs similarity index 86% rename from next.config.js rename to next.config.mjs index 551a900..6579620 100644 --- a/next.config.js +++ b/next.config.mjs @@ -1,4 +1,8 @@ -/** +import createNextIntlPlugin from 'next-intl/plugin' + +const withNextIntl = createNextIntlPlugin() + +/** * Undefined entries are not supported. Push optional patterns to this array only if defined. * @type {import('next/dist/shared/lib/image-config').RemotePattern} */ @@ -31,4 +35,4 @@ const nextConfig = { }, } -module.exports = nextConfig +export default withNextIntl(nextConfig) diff --git a/package-lock.json b/package-lock.json index 1509789..e609203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", "@hookform/resolvers": "^3.3.2", "@prisma/client": "^5.6.0", "@radix-ui/react-checkbox": "^1.0.4", @@ -33,7 +34,9 @@ "embla-carousel-react": "^8.0.0-rc21", "lucide-react": "^0.290.0", "nanoid": "^5.0.4", + "negotiator": "^0.6.3", "next": "^14.2.3", + "next-intl": "^3.17.2", "next-s3-upload": "^0.3.4", "next-themes": "^0.2.1", "next13-progressbar": "^1.1.1", @@ -55,6 +58,7 @@ "devDependencies": { "@total-typescript/ts-reset": "^0.5.1", "@types/content-disposition": "^0.5.8", + "@types/negotiator": "^0.6.3", "@types/node": "^20", "@types/pg": "^8.10.9", "@types/react": "^18.2.48", @@ -1006,6 +1010,50 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz", + "integrity": "sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==", + "dependencies": { + "@formatjs/intl-localematcher": "0.5.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.0.tgz", + "integrity": "sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.7.8", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.8.tgz", + "integrity": "sha512-nBZJYmhpcSX0WeJ5SDYUkZ42AgR3xiyhNCsQweFx3cz/ULJjym8bHAzWKvG5e2+1XO98dBYC0fWeeAECAVSwLA==", + "dependencies": { + "@formatjs/ecma402-abstract": "2.0.0", + "@formatjs/icu-skeleton-parser": "1.8.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.2.tgz", + "integrity": "sha512-k4ERKgw7aKGWJZgTarIcNEmvyTVD9FYh0mTrrBMHZ1b8hUu6iOJ4SzsZlo3UNAvHYa+PnvntIwRPt1/vy4nA9Q==", + "dependencies": { + "@formatjs/ecma402-abstract": "2.0.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz", + "integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@hookform/resolvers": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz", @@ -3116,6 +3164,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-JkXTOdKs5MF086b/pt8C3+yVp3iDUwG635L7oCH6HvJvvr6lSUU5oe/gLXnPEfYRROHjJIPgCV6cuAg8gGkntQ==", + "dev": true + }, "node_modules/@types/node": { "version": "20.8.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", @@ -5837,6 +5891,17 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "10.5.14", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.5.14.tgz", + "integrity": "sha512-IjC6sI0X7YRjjyVH9aUgdftcmZK7WXdHeil4KwbjDnRWjnVitKpAx3rr6t6di1joFp5188VqKcobOPA6mCLG/w==", + "dependencies": { + "@formatjs/ecma402-abstract": "2.0.0", + "@formatjs/fast-memoize": "2.2.0", + "@formatjs/icu-messageformat-parser": "2.7.8", + "tslib": "^2.4.0" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -6577,6 +6642,14 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", @@ -6626,6 +6699,34 @@ } } }, + "node_modules/next-intl": { + "version": "3.17.2", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.17.2.tgz", + "integrity": "sha512-X2ly23e1lC5vdWHaJFBDZi/0iornEdFQQtqJmmPOb7WD+LDssm9vAnx+hJshYGjddaP3rUmyWaPgePCQqaxm1g==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "dependencies": { + "@formatjs/intl-localematcher": "^0.2.32", + "negotiator": "^0.6.3", + "use-intl": "^3.17.2" + }, + "peerDependencies": { + "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/next-intl/node_modules/@formatjs/intl-localematcher": { + "version": "0.2.32", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz", + "integrity": "sha512-k/MEBstff4sttohyEpXxCmC3MqbUn9VvHGlZ8fauLzkbwXmVrEeyzS+4uhrvAk9DWU9/7otYWxyDox4nT/KVLQ==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/next-s3-upload": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/next-s3-upload/-/next-s3-upload-0.3.4.tgz", @@ -8674,6 +8775,18 @@ } } }, + "node_modules/use-intl": { + "version": "3.17.2", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.17.2.tgz", + "integrity": "sha512-9lPgt41nS8x4AYCLfIC9VKCmamnVxzPM2nze7lpp/I1uaSSQvIz5MQpYUFikv08cMUsCwAWahU0e+arHInpdcw==", + "dependencies": { + "@formatjs/fast-memoize": "^2.2.0", + "intl-messageformat": "^10.5.14" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/package.json b/package.json index 17e6686..e182e53 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "start-container": "docker compose --env-file container.env up" }, "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", "@hookform/resolvers": "^3.3.2", "@prisma/client": "^5.6.0", "@radix-ui/react-checkbox": "^1.0.4", @@ -39,7 +40,9 @@ "embla-carousel-react": "^8.0.0-rc21", "lucide-react": "^0.290.0", "nanoid": "^5.0.4", + "negotiator": "^0.6.3", "next": "^14.2.3", + "next-intl": "^3.17.2", "next-s3-upload": "^0.3.4", "next-themes": "^0.2.1", "next13-progressbar": "^1.1.1", @@ -61,6 +64,7 @@ "devDependencies": { "@total-typescript/ts-reset": "^0.5.1", "@types/content-disposition": "^0.5.8", + "@types/negotiator": "^0.6.3", "@types/node": "^20", "@types/pg": "^8.10.9", "@types/react": "^18.2.48", diff --git a/src/app/groups/[groupId]/activity/activity-item.tsx b/src/app/groups/[groupId]/activity/activity-item.tsx index 6fdc469..69f64e1 100644 --- a/src/app/groups/[groupId]/activity/activity-item.tsx +++ b/src/app/groups/[groupId]/activity/activity-item.tsx @@ -4,6 +4,7 @@ import { getGroupExpenses } from '@/lib/api' import { DateTimeStyle, cn, formatDate } from '@/lib/utils' import { Activity, ActivityType, Participant } from '@prisma/client' import { ChevronRight } from 'lucide-react' +import { useLocale, useTranslations } from 'next-intl' import Link from 'next/link' import { useRouter } from 'next/navigation' @@ -15,36 +16,27 @@ type Props = { dateStyle: DateTimeStyle } -function getSummary(activity: Activity, participantName?: string) { - const participant = participantName ?? 'Someone' +function useSummary(activity: Activity, participantName?: string) { + const t = useTranslations('Activity') + const participant = participantName ?? t('someone') const expense = activity.data ?? '' + + const tr = (key: string) => + t.rich(key, { + expense, + participant, + em: (chunks) => “{chunks}”, + strong: (chunks) => {chunks}, + }) + if (activity.activityType == ActivityType.UPDATE_GROUP) { - return ( - <> - Group settings were modified by {participant} - - ) + return <>{tr('settingsModified')} } else if (activity.activityType == ActivityType.CREATE_EXPENSE) { - return ( - <> - Expense “{expense}” created by{' '} - {participant}. - - ) + return <>{tr('expenseCreated')} } else if (activity.activityType == ActivityType.UPDATE_EXPENSE) { - return ( - <> - Expense “{expense}” updated by{' '} - {participant}. - - ) + return <>{tr('expenseUpdated')} } else if (activity.activityType == ActivityType.DELETE_EXPENSE) { - return ( - <> - Expense “{expense}” deleted by{' '} - {participant}. - - ) + return <>{tr('expenseDeleted')} } } @@ -56,9 +48,10 @@ export function ActivityItem({ dateStyle, }: Props) { const router = useRouter() + const locale = useLocale() const expenseExists = expense !== undefined - const summary = getSummary(activity, participant?.name) + const summary = useSummary(activity, participant?.name) return (
{dateStyle !== undefined && (
- {formatDate(activity.time, { dateStyle })} + {formatDate(activity.time, locale, { dateStyle })}
)}
- {formatDate(activity.time, { timeStyle: 'short' })} + {formatDate(activity.time, locale, { timeStyle: 'short' })}
diff --git a/src/app/groups/[groupId]/activity/activity-list.tsx b/src/app/groups/[groupId]/activity/activity-list.tsx index 1cde768..cf2c010 100644 --- a/src/app/groups/[groupId]/activity/activity-list.tsx +++ b/src/app/groups/[groupId]/activity/activity-list.tsx @@ -2,6 +2,7 @@ import { ActivityItem } from '@/app/groups/[groupId]/activity/activity-item' import { getGroupExpenses } from '@/lib/api' import { Activity, Participant } from '@prisma/client' import dayjs, { type Dayjs } from 'dayjs' +import { useTranslations } from 'next-intl' type Props = { groupId: string @@ -11,15 +12,15 @@ type Props = { } const DATE_GROUPS = { - TODAY: 'Today', - YESTERDAY: 'Yesterday', - EARLIER_THIS_WEEK: 'Earlier this week', - LAST_WEEK: 'Last week', - EARLIER_THIS_MONTH: 'Earlier this month', - LAST_MONTH: 'Last month', - EARLIER_THIS_YEAR: 'Earlier this year', - LAST_YEAR: 'Last year', - OLDER: 'Older', + TODAY: 'today', + YESTERDAY: 'yesterday', + EARLIER_THIS_WEEK: 'earlierThisWeek', + LAST_WEEK: 'lastWeek', + EARLIER_THIS_MONTH: 'earlierThisMonth', + LAST_MONTH: 'lastMonth', + EARLIER_THIS_YEAR: 'earlierThisYear', + LAST_YEAR: 'lastYear', + OLDER: 'older', } function getDateGroup(date: Dayjs, today: Dayjs) { @@ -63,6 +64,7 @@ export function ActivityList({ expenses, activities, }: Props) { + const t = useTranslations('Activity') const groupedActivitiesByDate = getGroupedActivitiesByDate(activities) return activities.length > 0 ? ( @@ -82,7 +84,7 @@ export function ActivityList({ 'text-muted-foreground text-xs py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]' } > - {dateGroup} + {t(`Groups.${dateGroup}`)}
{groupActivities.map((activity: Activity) => { const participant = @@ -105,8 +107,6 @@ export function ActivityList({ })} ) : ( -

- There is not yet any activity in your group. -

+

{t('noActivity')}

) } diff --git a/src/app/groups/[groupId]/activity/page.tsx b/src/app/groups/[groupId]/activity/page.tsx index 9fbe890..d02debf 100644 --- a/src/app/groups/[groupId]/activity/page.tsx +++ b/src/app/groups/[groupId]/activity/page.tsx @@ -9,6 +9,7 @@ import { } from '@/components/ui/card' import { getActivities, getGroupExpenses } from '@/lib/api' import { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' import { notFound } from 'next/navigation' export const metadata: Metadata = { @@ -20,6 +21,7 @@ export default async function ActivityPage({ }: { params: { groupId: string } }) { + const t = await getTranslations('Activity') const group = await cached.getGroup(groupId) if (!group) notFound() @@ -30,10 +32,8 @@ export default async function ActivityPage({ <> - Activity - - Overview of all activity in this group. - + {t('title')} + {t('description')} Math.abs(b.total)), ) @@ -28,7 +30,7 @@ export function BalancesList({ balances, participants, currency }: Props) {
- {formatCurrency(currency, balance)} + {formatCurrency(currency, balance, locale)}
{balance !== 0 && (
- Balances - - This is the amount that each participant paid or was paid for. - + {t('title')} + {t('description')} - Suggested reimbursements - - Here are suggestions for optimized reimbursements between - participants. - + {t('Reimbursements.title')} + {t('Reimbursements.description')} - Your balance:{' '} + {t('yourBalance')}{' '} {balanceDetail} diff --git a/src/app/groups/[groupId]/expenses/active-user-modal.tsx b/src/app/groups/[groupId]/expenses/active-user-modal.tsx index 5d9d005..27d8ad8 100644 --- a/src/app/groups/[groupId]/expenses/active-user-modal.tsx +++ b/src/app/groups/[groupId]/expenses/active-user-modal.tsx @@ -12,7 +12,6 @@ import { import { Drawer, DrawerContent, - DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, @@ -22,6 +21,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { getGroup } from '@/lib/api' import { useMediaQuery } from '@/lib/hooks' import { cn } from '@/lib/utils' +import { useTranslations } from 'next-intl' import { ComponentProps, useEffect, useState } from 'react' type Props = { @@ -29,6 +29,7 @@ type Props = { } export function ActiveUserModal({ group }: Props) { + const t = useTranslations('Expenses.ActiveUserModal') const [open, setOpen] = useState(false) const isDesktop = useMediaQuery('(min-width: 768px)') @@ -52,16 +53,13 @@ export function ActiveUserModal({ group }: Props) { - Who are you? - - Tell us which participant you are to let us customize how the - information is displayed. - + {t('title')} + {t('description')} setOpen(false)} />

- This setting can be changed later in the group settings. + {t('footer')}

@@ -73,11 +71,8 @@ export function ActiveUserModal({ group }: Props) { - Who are you? - - Tell us which participant you are to let us customize how the - information is displayed. - + {t('title')} + {t('description')}

- This setting can be changed later in the group settings. + {t('footer')}

@@ -99,6 +94,7 @@ function ActiveUserForm({ close, className, }: ComponentProps<'form'> & { group: Props['group']; close: () => void }) { + const t = useTranslations('Expenses.ActiveUserModal') const [selected, setSelected] = useState('None') return ( @@ -115,7 +111,7 @@ function ActiveUserForm({
{group.participants.map((participant) => ( @@ -128,7 +124,7 @@ function ActiveUserForm({ ))}
- + ) } diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx index 680fe33..4c276b5 100644 --- a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx +++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx @@ -29,6 +29,7 @@ import { useMediaQuery } from '@/lib/hooks' import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils' import { Category } from '@prisma/client' import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react' +import { useLocale, useTranslations } from 'next-intl' import { getImageData, usePresignedUpload } from 'next-s3-upload' import Image from 'next/image' import { useRouter } from 'next/navigation' @@ -47,6 +48,8 @@ export function CreateFromReceiptButton({ groupCurrency, categories, }: Props) { + const locale = useLocale() + const t = useTranslations('CreateFromReceipt') const [pending, setPending] = useState(false) const { uploadToS3, FileInput, openFileDialog } = usePresignedUpload() const { toast } = useToast() @@ -60,10 +63,11 @@ export function CreateFromReceiptButton({ const handleFileChange = async (file: File) => { if (file.size > MAX_FILE_SIZE) { toast({ - title: 'The file is too big', - description: `The maximum file size you can upload is ${formatFileSize( - MAX_FILE_SIZE, - )}. Yours is ${formatFileSize(file.size)}.`, + title: t('TooBigToast.title'), + description: t('TooBigToast.description', { + maxSize: formatFileSize(MAX_FILE_SIZE, locale), + size: formatFileSize(file.size, locale), + }), variant: 'destructive', }) return @@ -82,13 +86,15 @@ export function CreateFromReceiptButton({ } catch (err) { console.error(err) toast({ - title: 'Error while uploading document', - description: - 'Something wrong happened when uploading the document. Please retry later or select a different file.', + title: t('ErrorToast.title'), + description: t('ErrorToast.description'), variant: 'destructive', action: ( - upload()}> - Retry + upload()} + > + {t('ErrorToast.retry')} ), }) @@ -114,26 +120,23 @@ export function CreateFromReceiptButton({ } title={ <> - Create from receipt + {t('Dialog.title')} Beta } - description={<>Extract the expense information from a receipt photo.} + description={<>{t('Dialog.description')}} >
-

- Upload the photo of a receipt, and we’ll scan it to extract the - expense information if we can. -

+

{t('Dialog.body')}

) : ( - Select image… + {t('Dialog.selectImage')} )}
- Title: + {t('Dialog.titleLabel')}
{receiptInfo ? receiptInfo.title ?? : '…'}
- Category: + {t('Dialog.categoryLabel')}
{receiptInfo ? ( receiptInfoCategory ? ( @@ -194,11 +197,17 @@ export function CreateFromReceiptButton({
- Amount: + {t('Dialog.amountLabel')}
{receiptInfo ? ( receiptInfo.amount ? ( - <>{formatCurrency(groupCurrency, receiptInfo.amount)} + <> + {formatCurrency( + groupCurrency, + receiptInfo.amount, + locale, + )} + ) : ( ) @@ -208,13 +217,15 @@ export function CreateFromReceiptButton({
- Date: + {t('Dialog.dateLabel')}
{receiptInfo ? ( receiptInfo.date ? ( - formatDate(new Date(`${receiptInfo?.date}T12:00:00.000Z`), { - dateStyle: 'medium', - }) + formatDate( + new Date(`${receiptInfo?.date}T12:00:00.000Z`), + locale, + { dateStyle: 'medium' }, + ) ) : ( ) @@ -225,7 +236,7 @@ export function CreateFromReceiptButton({
-

You’ll be able to edit the expense information next.

+

{t('Dialog.editNext')}

@@ -253,10 +264,11 @@ export function CreateFromReceiptButton({ } function Unknown() { + const t = useTranslations('CreateFromReceipt') return (
- Unknown + {t('unknown')}
) } diff --git a/src/app/groups/[groupId]/expenses/expense-card.tsx b/src/app/groups/[groupId]/expenses/expense-card.tsx index 239f8fd..2ad4175 100644 --- a/src/app/groups/[groupId]/expenses/expense-card.tsx +++ b/src/app/groups/[groupId]/expenses/expense-card.tsx @@ -5,18 +5,40 @@ import { Button } from '@/components/ui/button' import { getGroupExpenses } from '@/lib/api' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { ChevronRight } from 'lucide-react' +import { useLocale, useTranslations } from 'next-intl' import Link from 'next/link' import { useRouter } from 'next/navigation' import { Fragment } from 'react' +type Expense = Awaited>[number] + +function Participants({ expense }: { expense: Expense }) { + const t = useTranslations('ExpenseCard') + const key = expense.amount > 0 ? 'paidBy' : 'receivedBy' + const paidFor = expense.paidFor.map((paidFor, index) => ( + + {index !== 0 && <>, } + {paidFor.participant.name} + + )) + const participants = t.rich(key, { + strong: (chunks) => {chunks}, + paidBy: expense.paidBy.name, + paidFor: () => paidFor, + forCount: expense.paidFor.length, + }) + return <>{participants} +} + type Props = { - expense: Awaited>[number] + expense: Expense currency: string groupId: string } export function ExpenseCard({ expense, currency, groupId }: Props) { const router = useRouter() + const locale = useLocale() return (
- {expense.amount > 0 ? 'Paid by ' : 'Received by '} - {expense.paidBy.name} for{' '} - {expense.paidFor.map((paidFor, index) => ( - - {index !== 0 && <>, } - {paidFor.participant.name} - - ))} +
@@ -58,10 +73,10 @@ export function ExpenseCard({ expense, currency, groupId }: Props) { expense.isReimbursement ? 'italic' : 'font-bold', )} > - {formatCurrency(currency, expense.amount)} + {formatCurrency(currency, expense.amount, locale)}
- {formatDate(expense.expenseDate, { dateStyle: 'medium' })} + {formatDate(expense.expenseDate, locale, { dateStyle: 'medium' })}
{groupExpenses.map((expense) => ( ) : (

- Your group doesn’t contain any expense yet.{' '} + {t('noExpenses')}{' '}

diff --git a/src/app/groups/[groupId]/expenses/page.tsx b/src/app/groups/[groupId]/expenses/page.tsx index d421fc2..068d46f 100644 --- a/src/app/groups/[groupId]/expenses/page.tsx +++ b/src/app/groups/[groupId]/expenses/page.tsx @@ -19,6 +19,7 @@ import { import { env } from '@/lib/env' import { Download, Plus } from 'lucide-react' import { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' import Link from 'next/link' import { notFound } from 'next/navigation' import { Suspense } from 'react' @@ -34,6 +35,7 @@ export default async function GroupExpensesPage({ }: { params: { groupId: string } }) { + const t = await getTranslations('Expenses') const group = await cached.getGroup(groupId) if (!group) notFound() @@ -44,10 +46,8 @@ export default async function GroupExpensesPage({
- Expenses - - Here are the expenses that you created for your group. - + {t('title')} + {t('description')}
-
{formatCurrency(currency, reimbursement.amount)}
+
{formatCurrency(currency, reimbursement.amount, locale)}
))} diff --git a/src/app/groups/[groupId]/share-button.tsx b/src/app/groups/[groupId]/share-button.tsx index 9addb29..0b2d000 100644 --- a/src/app/groups/[groupId]/share-button.tsx +++ b/src/app/groups/[groupId]/share-button.tsx @@ -11,27 +11,26 @@ import { import { useBaseUrl } from '@/lib/hooks' import { Group } from '@prisma/client' import { Share } from 'lucide-react' +import { useTranslations } from 'next-intl' type Props = { group: Group } export function ShareButton({ group }: Props) { + const t = useTranslations('Share') const baseUrl = useBaseUrl() const url = baseUrl && `${baseUrl}/groups/${group.id}/expenses?ref=share` return ( - -

- For other participants to see the group and add expenses, share its - URL with them. -

+

{t('description')}

{url && (
@@ -43,8 +42,7 @@ export function ShareButton({ group }: Props) {
)}

- Warning! Every person with the group URL will be able - to see and edit expenses. Share with caution! + {t('warning')} {t('warningHelp')}

diff --git a/src/app/groups/[groupId]/stats/page.tsx b/src/app/groups/[groupId]/stats/page.tsx index eb7fa9e..3cb42cf 100644 --- a/src/app/groups/[groupId]/stats/page.tsx +++ b/src/app/groups/[groupId]/stats/page.tsx @@ -10,6 +10,7 @@ import { import { getGroupExpenses } from '@/lib/api' import { getTotalGroupSpending } from '@/lib/totals' import { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' import { notFound } from 'next/navigation' export const metadata: Metadata = { @@ -21,6 +22,7 @@ export default async function TotalsPage({ }: { params: { groupId: string } }) { + const t = await getTranslations('Stats') const group = await cached.getGroup(groupId) if (!group) notFound() @@ -31,10 +33,8 @@ export default async function TotalsPage({ <> - Totals - - Spending summary of the entire group. - + {t('Totals.title')} + {t('Totals.description')} -
Total group {balance}
+
{t(balance)}
- {formatCurrency(currency, Math.abs(totalGroupSpendings))} + {formatCurrency(currency, Math.abs(totalGroupSpendings), locale)}
) diff --git a/src/app/groups/[groupId]/stats/totals-your-share.tsx b/src/app/groups/[groupId]/stats/totals-your-share.tsx index 3c7b7cb..9c441cf 100644 --- a/src/app/groups/[groupId]/stats/totals-your-share.tsx +++ b/src/app/groups/[groupId]/stats/totals-your-share.tsx @@ -2,6 +2,7 @@ import { getGroup, getGroupExpenses } from '@/lib/api' import { getTotalActiveUserShare } from '@/lib/totals' import { cn, formatCurrency } from '@/lib/utils' +import { useLocale, useTranslations } from 'next-intl' import { useEffect, useState } from 'react' type Props = { @@ -10,6 +11,8 @@ type Props = { } export function TotalsYourShare({ group, expenses }: Props) { + const locale = useLocale() + const t = useTranslations('Stats.Totals') const [activeUser, setActiveUser] = useState('') useEffect(() => { @@ -25,14 +28,14 @@ export function TotalsYourShare({ group, expenses }: Props) { return (
-
Your total share
+
{t('yourShare')}
- {formatCurrency(currency, Math.abs(totalActiveUserShare))} + {formatCurrency(currency, Math.abs(totalActiveUserShare), locale)}
) diff --git a/src/app/groups/[groupId]/stats/totals-your-spending.tsx b/src/app/groups/[groupId]/stats/totals-your-spending.tsx index 137574b..3cb5353 100644 --- a/src/app/groups/[groupId]/stats/totals-your-spending.tsx +++ b/src/app/groups/[groupId]/stats/totals-your-spending.tsx @@ -3,6 +3,7 @@ import { getGroup, getGroupExpenses } from '@/lib/api' import { useActiveUser } from '@/lib/hooks' import { getTotalActiveUserPaidFor } from '@/lib/totals' import { cn, formatCurrency } from '@/lib/utils' +import { useLocale, useTranslations } from 'next-intl' type Props = { group: NonNullable>> @@ -10,6 +11,8 @@ type Props = { } export function TotalsYourSpendings({ group, expenses }: Props) { + const locale = useLocale() + const t = useTranslations('Stats.Totals') const activeUser = useActiveUser(group.id) const totalYourSpendings = @@ -17,11 +20,11 @@ export function TotalsYourSpendings({ group, expenses }: Props) { ? 0 : getTotalActiveUserPaidFor(activeUser, expenses) const currency = group.currency - const balance = totalYourSpendings < 0 ? 'earnings' : 'spendings' + const balance = totalYourSpendings < 0 ? 'yourEarnings' : 'yourSpendings' return (
-
Your total {balance}
+
{t(balance)}
- {formatCurrency(currency, Math.abs(totalYourSpendings))} + {formatCurrency(currency, Math.abs(totalYourSpendings), locale)}
) diff --git a/src/app/groups/add-group-by-url-button.tsx b/src/app/groups/add-group-by-url-button.tsx index a33f5eb..a4053d7 100644 --- a/src/app/groups/add-group-by-url-button.tsx +++ b/src/app/groups/add-group-by-url-button.tsx @@ -9,6 +9,7 @@ import { } from '@/components/ui/popover' import { useMediaQuery } from '@/lib/hooks' import { Loader2, Plus } from 'lucide-react' +import { useTranslations } from 'next-intl' import { useState } from 'react' type Props = { @@ -16,6 +17,7 @@ type Props = { } export function AddGroupByUrlButton({ reload }: Props) { + const t = useTranslations('Groups.AddByURL') const isDesktop = useMediaQuery('(min-width: 640px)') const [url, setUrl] = useState('') const [error, setError] = useState(false) @@ -27,18 +29,15 @@ export function AddGroupByUrlButton({ reload }: Props) { -

Add a group by URL

-

- If a group was shared with you, you can paste its URL here to add it - to your list. -

+

{t('title')}

+

{t('description')}

{ @@ -80,11 +79,7 @@ export function AddGroupByUrlButton({ reload }: Props) { )}
- {error && ( -

- Oops, we are not able to find the group from the URL you provided… -

- )} + {error &&

{t('error')}

}
) diff --git a/src/app/groups/not-found.tsx b/src/app/groups/not-found.tsx index d4532e1..54b2bae 100644 --- a/src/app/groups/not-found.tsx +++ b/src/app/groups/not-found.tsx @@ -1,13 +1,15 @@ import { Button } from '@/components/ui/button' +import { useTranslations } from 'next-intl' import Link from 'next/link' export default function NotFound() { + const t = useTranslations('Groups.NotFound') return (
-

This group does not exist.

+

{t('text')}

diff --git a/src/app/groups/recent-group-list-card.tsx b/src/app/groups/recent-group-list-card.tsx index 0218fb8..3069210 100644 --- a/src/app/groups/recent-group-list-card.tsx +++ b/src/app/groups/recent-group-list-card.tsx @@ -23,6 +23,7 @@ import { ToastAction } from '@/components/ui/toast' import { useToast } from '@/components/ui/use-toast' import { StarFilledIcon } from '@radix-ui/react-icons' import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react' +import { useLocale, useTranslations } from 'next-intl' import Link from 'next/link' import { useRouter } from 'next/navigation' import { SetStateAction } from 'react' @@ -37,7 +38,9 @@ export function RecentGroupListCard({ setState: (state: SetStateAction) => void }) { const router = useRouter() + const locale = useLocale() const toast = useToast() + const t = useTranslations('Groups') const details = state.status === 'complete' @@ -118,12 +121,11 @@ export function RecentGroupListCard({ groups: state.groups.filter((g) => g.id !== group.id), }) toast.toast({ - title: 'Group has been removed', - description: - 'The group was removed from your recent groups list.', + title: t('RecentRemovedToast.title'), + description: t('RecentRemovedToast.description'), action: ( { saveRecentGroup(group) setState({ @@ -132,13 +134,13 @@ export function RecentGroupListCard({ }) }} > - Undo + {t('RecentRemovedToast.undo')} ), }) }} > - Remove from recent groups + {t('removeRecent')} { @@ -152,7 +154,7 @@ export function RecentGroupListCard({ refreshGroupsFromStorage() }} > - {isArchived ? <>Unarchive group : <>Archive group} + {t(isArchived ? 'unarchive' : 'archive')} @@ -168,7 +170,7 @@ export function RecentGroupListCard({
- {new Date(details.createdAt).toLocaleDateString('en-US', { + {new Date(details.createdAt).toLocaleDateString(locale, { dateStyle: 'medium', })} diff --git a/src/app/groups/recent-group-list.tsx b/src/app/groups/recent-group-list.tsx index b646997..ad01d28 100644 --- a/src/app/groups/recent-group-list.tsx +++ b/src/app/groups/recent-group-list.tsx @@ -10,6 +10,7 @@ import { import { Button } from '@/components/ui/button' import { getGroups } from '@/lib/api' import { Loader2 } from 'lucide-react' +import { useTranslations } from 'next-intl' import Link from 'next/link' import { PropsWithChildren, SetStateAction, useEffect, useState } from 'react' import { RecentGroupListCard } from './recent-group-list-card' @@ -53,6 +54,7 @@ function sortGroups( } export function RecentGroupList() { + const t = useTranslations('Groups') const [state, setState] = useState({ status: 'pending' }) function loadGroups() { @@ -84,8 +86,8 @@ export function RecentGroupList() { return (

- Loading - recent groups… + {' '} + {t('loadingRecent')}

) @@ -95,12 +97,12 @@ export function RecentGroupList() { return (
-

You have not visited any group recently.

+

{t('NoRecent.description')}

{' '} - or ask a friend to send you the link to an existing one. + {t('NoRecent.orAsk')}

@@ -113,7 +115,7 @@ export function RecentGroupList() { {starredGroupInfo.length > 0 && ( <> -

Starred groups

+

{t('starred')}

0 && ( <> -

Recent groups

+

{t('recent')}

)} {archivedGroupInfo.length > 0 && ( <> -

Archived groups

+

{t('archived')}

void }>) { + const t = useTranslations('Groups') return ( <>

- My groups + {t('myGroups')}

diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 73747fb..eaa068a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import { ApplePwaSplash } from '@/app/apple-pwa-splash' +import { LocaleSwitcher } from '@/components/locale-switcher' import { ProgressBar } from '@/components/progress-bar' import { ThemeProvider } from '@/components/theme-provider' import { ThemeToggle } from '@/components/theme-toggle' @@ -6,6 +7,8 @@ import { Button } from '@/components/ui/button' import { Toaster } from '@/components/ui/toaster' import { env } from '@/lib/env' import type { Metadata, Viewport } from 'next' +import { NextIntlClientProvider, useTranslations } from 'next-intl' +import { getLocale, getMessages } from 'next-intl/server' import Image from 'next/image' import Link from 'next/link' import { Suspense } from 'react' @@ -59,93 +62,109 @@ export const viewport: Viewport = { themeColor: '#047857', } -export default function RootLayout({ - children, -}: { - children: React.ReactNode -}) { +function Content({ children }: { children: React.ReactNode }) { + const t = useTranslations() return ( - - - - +
+ - - - -
- -

- Spliit -

+

+ Spliit +

+ +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+ +
{children}
+ +
+
+
+ + Spliit -
-
    -
  • - -
  • -
  • - -
  • -
-
-
- -
{children}
- -
-
-
- - Spliit - -
-
- Made in Montréal, Québec 🇨🇦 - - Built by{' '} +
+
+ {t('Footer.madeIn')} + + {t.rich('Footer.builtBy', { + author: (txt) => ( - Sebastien Castiel - {' '} - and{' '} + {txt} + + ), + source: (txt) => ( - contributors + {txt} - -
-
-
- -
+ ), + })} + +
+
+ + + + ) +} + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + const locale = await getLocale() + const messages = await getMessages() + return ( + + + + + + + + + {children} + + ) diff --git a/src/components/category-selector.tsx b/src/components/category-selector.tsx index 05c3be0..46ec31f 100644 --- a/src/components/category-selector.tsx +++ b/src/components/category-selector.tsx @@ -17,6 +17,7 @@ import { } from '@/components/ui/popover' import { useMediaQuery } from '@/lib/hooks' import { Category } from '@prisma/client' +import { useTranslations } from 'next-intl' import { forwardRef, useEffect, useState } from 'react' type Props = { @@ -100,6 +101,7 @@ function CategoryCommand({ categories: Category[] onValueChange: (categoryId: Category['id']) => void }) { + const t = useTranslations('Categories') const categoriesByGroup = categories.reduce>( (acc, category) => ({ ...acc, @@ -110,16 +112,18 @@ function CategoryCommand({ return ( - - No category found. + + {t('noCategory')}
{Object.entries(categoriesByGroup).map( ([group, groupCategories], index) => ( - + {groupCategories.map((category) => ( { const id = Number(currentValue.split(' ')[0]) onValueChange(id) @@ -169,10 +173,11 @@ const CategoryButton = forwardRef( CategoryButton.displayName = 'CategoryButton' function CategoryLabel({ category }: { category: Category }) { + const t = useTranslations('Categories') return (
- {category.name} + {t(`${category.grouping}.${category.name}`)}
) } diff --git a/src/components/delete-popup.tsx b/src/components/delete-popup.tsx index 10b36a3..6e7d75a 100644 --- a/src/components/delete-popup.tsx +++ b/src/components/delete-popup.tsx @@ -1,6 +1,7 @@ 'use client' import { Trash2 } from 'lucide-react' +import { useTranslations } from 'next-intl' import { AsyncButton } from './async-button' import { Button } from './ui/button' import { @@ -14,20 +15,18 @@ import { } from './ui/dialog' export function DeletePopup({ onDelete }: { onDelete: () => Promise }) { + const t = useTranslations('ExpenseForm.DeletePopup') return ( - Delete this expense? - - Do you really want to delete this expense? This action is - irreversible. - + {t('title')} + {t('description')} Promise }) { loadingContent="Deleting…" action={onDelete} > - Yes + {t('yes')} - + diff --git a/src/components/expense-documents-input.tsx b/src/components/expense-documents-input.tsx index ae8a272..2670600 100644 --- a/src/components/expense-documents-input.tsx +++ b/src/components/expense-documents-input.tsx @@ -19,6 +19,7 @@ import { randomId } from '@/lib/api' import { ExpenseFormValues } from '@/lib/schemas' import { formatFileSize } from '@/lib/utils' import { Loader2, Plus, Trash, X } from 'lucide-react' +import { useLocale, useTranslations } from 'next-intl' import { getImageData, usePresignedUpload } from 'next-s3-upload' import Image from 'next/image' import { useEffect, useState } from 'react' @@ -31,6 +32,8 @@ type Props = { const MAX_FILE_SIZE = 5 * 1024 ** 2 export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) { + const locale = useLocale() + const t = useTranslations('ExpenseDocumentsInput') const [pending, setPending] = useState(false) const { FileInput, openFileDialog, uploadToS3 } = usePresignedUpload() // use presigned uploads to addtionally support providers other than AWS const { toast } = useToast() @@ -38,10 +41,11 @@ export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) { const handleFileChange = async (file: File) => { if (file.size > MAX_FILE_SIZE) { toast({ - title: 'The file is too big', - description: `The maximum file size you can upload is ${formatFileSize( - MAX_FILE_SIZE, - )}. Yours is ${formatFileSize(file.size)}.`, + title: t('TooBigToast.title'), + description: t('TooBigToast.description', { + maxSize: formatFileSize(MAX_FILE_SIZE, locale), + size: formatFileSize(file.size, locale), + }), variant: 'destructive', }) return @@ -57,13 +61,15 @@ export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) { } catch (err) { console.error(err) toast({ - title: 'Error while uploading document', - description: - 'Something wrong happened when uploading the document. Please retry later or select a different file.', + title: t('ErrorToast.title'), + description: t('ErrorToast.description'), variant: 'destructive', action: ( - upload()}> - Retry + upload()} + > + {t('ErrorToast.retry')} ), }) diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx index b05e359..1a296db 100644 --- a/src/components/expense-form.tsx +++ b/src/components/expense-form.tsx @@ -44,6 +44,7 @@ import { import { cn } from '@/lib/utils' import { zodResolver } from '@hookform/resolvers/zod' import { Save } from 'lucide-react' +import { useTranslations } from 'next-intl' import Link from 'next/link' import { useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' @@ -71,9 +72,6 @@ const enforceCurrencyPattern = (value: string) => .replace(/#/, '.') // change back # to dot .replace(/[^-\d.]/g, '') // remove all non-numeric characters -const capitalize = (value: string) => - value.charAt(0).toUpperCase() + value.slice(1) - const getDefaultSplittingOptions = (group: Props['group']) => { const defaultValue = { splitMode: 'EVENLY' as const, @@ -154,6 +152,7 @@ export function ExpenseForm({ onDelete, runtimeFeatureFlags, }: Props) { + const t = useTranslations('ExpenseForm') const isCreate = expense === undefined const searchParams = useSearchParams() const getSelectedPayer = (field?: { value: string }) => { @@ -249,7 +248,7 @@ export function ExpenseForm({ Set >(new Set()) - const sExpense = isIncome ? 'income' : 'expense' + const sExpense = isIncome ? 'Income' : 'Expense' const sPaid = isIncome ? 'received' : 'paid' useEffect(() => { @@ -322,7 +321,9 @@ export function ExpenseForm({
- {(isCreate ? 'Create ' : 'Edit ') + sExpense} + + {t(`${sExpense}.${isCreate ? 'create' : 'edit'}`)} + ( - {capitalize(sExpense)} title + {t(`${sExpense}.TitleField.label`)} { @@ -350,7 +351,7 @@ export function ExpenseForm({ /> - Enter a description for the {sExpense}. + {t(`${sExpense}.TitleField.description`)} @@ -362,7 +363,7 @@ export function ExpenseForm({ name="expenseDate" render={({ field }) => ( - {capitalize(sExpense)} date + {t(`${sExpense}.DateField.label`)} - Enter the date the {sExpense} was {sPaid}. + {t(`${sExpense}.DateField.description`)} @@ -386,7 +387,7 @@ export function ExpenseForm({ name="amount" render={({ field: { onChange, ...field } }) => ( - Amount + {t('amountField.label')}
{group.currency} @@ -426,7 +427,9 @@ export function ExpenseForm({ />
- This is a reimbursement + + {t('isReimbursementField.label')} +
)} @@ -441,7 +444,7 @@ export function ExpenseForm({ name="category" render={({ field }) => ( - Category + {t('categoryField.label')} - Select the {sExpense} category. + {t(`${sExpense}.categoryFieldDescription`)} @@ -463,7 +466,7 @@ export function ExpenseForm({ name="paidBy" render={({ field }) => ( - {capitalize(sPaid)} by + {t(`${sExpense}.paidByField.label`)} - Select the participant who {sPaid} the {sExpense}. + {t(`${sExpense}.paidByField.description`)} @@ -491,7 +494,7 @@ export function ExpenseForm({ name="notes" render={({ field }) => ( - Notes + {t('notesField.label')}