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) {
-
+
)
}
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')}
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 by URL>
+ {t('button')}
- 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')}
- {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')}
- Go to recently visited groups
+ {t('link')}
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')}
- Create one
+ {t('NoRecent.create')}
{' '}
- 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')}
{/* */}
- <>Create>
+ {t('create')}
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 (
-
-
-
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ -
+
+ {t('Header.groups')}
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+ {children}
+
+
+
+ >
+ )
+}
+
+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 (