Internationalization + Finnish language (#181)

* I18n with next-intl

* package-lock

* Finnish translations

* Development fix

* Use locale for positioning currency symbol

* Translations: Expenses.ActiveUserModal

* Translations: group 404

* Better translation for ExpenseCard

* Apply translations in CategorySelect search

* Fix for Finnish translation

* Translations for ExpenseDocumentsInput

* Translations for CreateFromReceipt

* Fix for Finnish translation

* Translations for schema errors

* Fix for Finnish translation

* Fixes for Finnish translations

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
This commit is contained in:
Tuomas Jaakola
2024-08-02 18:26:23 +03:00
committed by GitHub
parent c392c06b39
commit 4f5e124ff0
41 changed files with 1439 additions and 396 deletions

374
messages/en-US.json Normal file
View File

@@ -0,0 +1,374 @@
{
"Header": {
"groups": "Groups"
},
"Footer": {
"madeIn": "Made in Montréal, Québec 🇨🇦",
"builtBy": "Built by <author>Sebastien Castiel</author> and <source>contributors</source>"
},
"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 doesnt 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 dont 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 <strong>{paidBy}</strong> for <paidFor></paidFor>",
"receivedBy": "Received by <strong>{paidBy}</strong> for <paidFor></paidFor>",
"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": "Well 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 well scan it to extract the expense information if we can.",
"selectImage": "Select image…",
"titleLabel": "Title:",
"categoryLabel": "Category:",
"amountLabel": "Amount:",
"dateLabel": "Date:",
"editNext": "Youll 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 doesnt need any reimbursement 😁",
"owes": "<strong>{from}</strong> owes <strong>{to}</strong>",
"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 <strong>{participant}</strong>.",
"expenseCreated": "Expense <em>{expense}</em> created by <strong>{participant}</strong>.",
"expenseUpdated": "Expense <em>{expense}</em> updated by <strong>{participant}</strong>.",
"expenseDeleted": "Expense <em>{expense}</em> deleted by <strong>{participant}</strong>.",
"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"
}
}
}

374
messages/fi.json Normal file
View File

@@ -0,0 +1,374 @@
{
"Header": {
"groups": "Ryhmät"
},
"Footer": {
"madeIn": "Made in Montréal, Québec 🇨🇦",
"builtBy": "Tekijät: <author>Sebastien Castiel</author> ja <source>muut osallistujat</source>"
},
"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": "<strong>{paidBy}</strong> maksoi {forCount, plural, =1 {henkilön} other {henkilöiden}} <paidFor></paidFor> puolesta",
"receivedBy": "<strong>{paidBy}</strong> sai rahaa {forCount, plural, =1 {henkilön} other {henkilöiden}} <paidFor></paidFor> 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": "<strong>{from}</strong> maksaa henkilölle <strong>{to}</strong>",
"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": "<strong>{participant}</strong> muokkasi ryhmän asetuksia.",
"expenseCreated": "<strong>{participant}</strong> lisäsi kulun <em>{expense}</em>.",
"expenseUpdated": "<strong>{participant}</strong> muokkasi kulua <em>{expense}</em>.",
"expenseDeleted": "<strong>{participant}</strong> poisti kulun <em>{expense}</em>.",
"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"
}
}
}

View File

@@ -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. * Undefined entries are not supported. Push optional patterns to this array only if defined.
* @type {import('next/dist/shared/lib/image-config').RemotePattern} * @type {import('next/dist/shared/lib/image-config').RemotePattern}
*/ */
@@ -31,4 +35,4 @@ const nextConfig = {
}, },
} }
module.exports = nextConfig export default withNextIntl(nextConfig)

113
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@hookform/resolvers": "^3.3.2", "@hookform/resolvers": "^3.3.2",
"@prisma/client": "^5.6.0", "@prisma/client": "^5.6.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
@@ -33,7 +34,9 @@
"embla-carousel-react": "^8.0.0-rc21", "embla-carousel-react": "^8.0.0-rc21",
"lucide-react": "^0.290.0", "lucide-react": "^0.290.0",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"negotiator": "^0.6.3",
"next": "^14.2.3", "next": "^14.2.3",
"next-intl": "^3.17.2",
"next-s3-upload": "^0.3.4", "next-s3-upload": "^0.3.4",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1", "next13-progressbar": "^1.1.1",
@@ -55,6 +58,7 @@
"devDependencies": { "devDependencies": {
"@total-typescript/ts-reset": "^0.5.1", "@total-typescript/ts-reset": "^0.5.1",
"@types/content-disposition": "^0.5.8", "@types/content-disposition": "^0.5.8",
"@types/negotiator": "^0.6.3",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.10.9", "@types/pg": "^8.10.9",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
@@ -1006,6 +1010,50 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
"integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" "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": { "node_modules/@hookform/resolvers": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz",
@@ -3116,6 +3164,12 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "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": { "node_modules/@types/node": {
"version": "20.8.9", "version": "20.8.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz",
@@ -5837,6 +5891,17 @@
"node": ">= 0.4" "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": { "node_modules/invariant": {
"version": "2.2.4", "version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -6577,6 +6642,14 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/next": {
"version": "14.2.3", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", "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": { "node_modules/next-s3-upload": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/next-s3-upload/-/next-s3-upload-0.3.4.tgz", "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": { "node_modules/use-sidecar": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",

View File

@@ -15,6 +15,7 @@
"start-container": "docker compose --env-file container.env up" "start-container": "docker compose --env-file container.env up"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@hookform/resolvers": "^3.3.2", "@hookform/resolvers": "^3.3.2",
"@prisma/client": "^5.6.0", "@prisma/client": "^5.6.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
@@ -39,7 +40,9 @@
"embla-carousel-react": "^8.0.0-rc21", "embla-carousel-react": "^8.0.0-rc21",
"lucide-react": "^0.290.0", "lucide-react": "^0.290.0",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"negotiator": "^0.6.3",
"next": "^14.2.3", "next": "^14.2.3",
"next-intl": "^3.17.2",
"next-s3-upload": "^0.3.4", "next-s3-upload": "^0.3.4",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1", "next13-progressbar": "^1.1.1",
@@ -61,6 +64,7 @@
"devDependencies": { "devDependencies": {
"@total-typescript/ts-reset": "^0.5.1", "@total-typescript/ts-reset": "^0.5.1",
"@types/content-disposition": "^0.5.8", "@types/content-disposition": "^0.5.8",
"@types/negotiator": "^0.6.3",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.10.9", "@types/pg": "^8.10.9",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",

View File

@@ -4,6 +4,7 @@ import { getGroupExpenses } from '@/lib/api'
import { DateTimeStyle, cn, formatDate } from '@/lib/utils' import { DateTimeStyle, cn, formatDate } from '@/lib/utils'
import { Activity, ActivityType, Participant } from '@prisma/client' import { Activity, ActivityType, Participant } from '@prisma/client'
import { ChevronRight } from 'lucide-react' import { ChevronRight } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
@@ -15,36 +16,27 @@ type Props = {
dateStyle: DateTimeStyle dateStyle: DateTimeStyle
} }
function getSummary(activity: Activity, participantName?: string) { function useSummary(activity: Activity, participantName?: string) {
const participant = participantName ?? 'Someone' const t = useTranslations('Activity')
const participant = participantName ?? t('someone')
const expense = activity.data ?? '' const expense = activity.data ?? ''
const tr = (key: string) =>
t.rich(key, {
expense,
participant,
em: (chunks) => <em>&ldquo;{chunks}&rdquo;</em>,
strong: (chunks) => <strong>{chunks}</strong>,
})
if (activity.activityType == ActivityType.UPDATE_GROUP) { if (activity.activityType == ActivityType.UPDATE_GROUP) {
return ( return <>{tr('settingsModified')}</>
<>
Group settings were modified by <strong>{participant}</strong>
</>
)
} else if (activity.activityType == ActivityType.CREATE_EXPENSE) { } else if (activity.activityType == ActivityType.CREATE_EXPENSE) {
return ( return <>{tr('expenseCreated')}</>
<>
Expense <em>&ldquo;{expense}&rdquo;</em> created by{' '}
<strong>{participant}</strong>.
</>
)
} else if (activity.activityType == ActivityType.UPDATE_EXPENSE) { } else if (activity.activityType == ActivityType.UPDATE_EXPENSE) {
return ( return <>{tr('expenseUpdated')}</>
<>
Expense <em>&ldquo;{expense}&rdquo;</em> updated by{' '}
<strong>{participant}</strong>.
</>
)
} else if (activity.activityType == ActivityType.DELETE_EXPENSE) { } else if (activity.activityType == ActivityType.DELETE_EXPENSE) {
return ( return <>{tr('expenseDeleted')}</>
<>
Expense <em>&ldquo;{expense}&rdquo;</em> deleted by{' '}
<strong>{participant}</strong>.
</>
)
} }
} }
@@ -56,9 +48,10 @@ export function ActivityItem({
dateStyle, dateStyle,
}: Props) { }: Props) {
const router = useRouter() const router = useRouter()
const locale = useLocale()
const expenseExists = expense !== undefined const expenseExists = expense !== undefined
const summary = getSummary(activity, participant?.name) const summary = useSummary(activity, participant?.name)
return ( return (
<div <div
@@ -75,11 +68,11 @@ export function ActivityItem({
<div className="flex flex-col justify-between items-start"> <div className="flex flex-col justify-between items-start">
{dateStyle !== undefined && ( {dateStyle !== undefined && (
<div className="mt-1 text-xs/5 text-muted-foreground"> <div className="mt-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { dateStyle })} {formatDate(activity.time, locale, { dateStyle })}
</div> </div>
)} )}
<div className="my-1 text-xs/5 text-muted-foreground"> <div className="my-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { timeStyle: 'short' })} {formatDate(activity.time, locale, { timeStyle: 'short' })}
</div> </div>
</div> </div>
<div className="flex-1"> <div className="flex-1">

View File

@@ -2,6 +2,7 @@ import { ActivityItem } from '@/app/groups/[groupId]/activity/activity-item'
import { getGroupExpenses } from '@/lib/api' import { getGroupExpenses } from '@/lib/api'
import { Activity, Participant } from '@prisma/client' import { Activity, Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs' import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl'
type Props = { type Props = {
groupId: string groupId: string
@@ -11,15 +12,15 @@ type Props = {
} }
const DATE_GROUPS = { const DATE_GROUPS = {
TODAY: 'Today', TODAY: 'today',
YESTERDAY: 'Yesterday', YESTERDAY: 'yesterday',
EARLIER_THIS_WEEK: 'Earlier this week', EARLIER_THIS_WEEK: 'earlierThisWeek',
LAST_WEEK: 'Last week', LAST_WEEK: 'lastWeek',
EARLIER_THIS_MONTH: 'Earlier this month', EARLIER_THIS_MONTH: 'earlierThisMonth',
LAST_MONTH: 'Last month', LAST_MONTH: 'lastMonth',
EARLIER_THIS_YEAR: 'Earlier this year', EARLIER_THIS_YEAR: 'earlierThisYear',
LAST_YEAR: 'Last year', LAST_YEAR: 'lastYear',
OLDER: 'Older', OLDER: 'older',
} }
function getDateGroup(date: Dayjs, today: Dayjs) { function getDateGroup(date: Dayjs, today: Dayjs) {
@@ -63,6 +64,7 @@ export function ActivityList({
expenses, expenses,
activities, activities,
}: Props) { }: Props) {
const t = useTranslations('Activity')
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities) const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
return activities.length > 0 ? ( 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]' 'text-muted-foreground text-xs py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
} }
> >
{dateGroup} {t(`Groups.${dateGroup}`)}
</div> </div>
{groupActivities.map((activity: Activity) => { {groupActivities.map((activity: Activity) => {
const participant = const participant =
@@ -105,8 +107,6 @@ export function ActivityList({
})} })}
</> </>
) : ( ) : (
<p className="px-6 text-sm py-6"> <p className="px-6 text-sm py-6">{t('noActivity')}</p>
There is not yet any activity in your group.
</p>
) )
} }

View File

@@ -9,6 +9,7 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { getActivities, getGroupExpenses } from '@/lib/api' import { getActivities, getGroupExpenses } from '@/lib/api'
import { Metadata } from 'next' import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -20,6 +21,7 @@ export default async function ActivityPage({
}: { }: {
params: { groupId: string } params: { groupId: string }
}) { }) {
const t = await getTranslations('Activity')
const group = await cached.getGroup(groupId) const group = await cached.getGroup(groupId)
if (!group) notFound() if (!group) notFound()
@@ -30,10 +32,8 @@ export default async function ActivityPage({
<> <>
<Card className="mb-4"> <Card className="mb-4">
<CardHeader> <CardHeader>
<CardTitle>Activity</CardTitle> <CardTitle>{t('title')}</CardTitle>
<CardDescription> <CardDescription>{t('description')}</CardDescription>
Overview of all activity in this group.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col space-y-4"> <CardContent className="flex flex-col space-y-4">
<ActivityList <ActivityList

View File

@@ -1,6 +1,7 @@
import { Balances } from '@/lib/balances' import { Balances } from '@/lib/balances'
import { cn, formatCurrency } from '@/lib/utils' import { cn, formatCurrency } from '@/lib/utils'
import { Participant } from '@prisma/client' import { Participant } from '@prisma/client'
import { useLocale } from 'next-intl'
type Props = { type Props = {
balances: Balances balances: Balances
@@ -9,6 +10,7 @@ type Props = {
} }
export function BalancesList({ balances, participants, currency }: Props) { export function BalancesList({ balances, participants, currency }: Props) {
const locale = useLocale()
const maxBalance = Math.max( const maxBalance = Math.max(
...Object.values(balances).map((b) => Math.abs(b.total)), ...Object.values(balances).map((b) => Math.abs(b.total)),
) )
@@ -28,7 +30,7 @@ export function BalancesList({ balances, participants, currency }: Props) {
</div> </div>
<div className={cn('w-1/2 relative', isLeft || 'text-right')}> <div className={cn('w-1/2 relative', isLeft || 'text-right')}>
<div className="absolute inset-0 p-2 z-20"> <div className="absolute inset-0 p-2 z-20">
{formatCurrency(currency, balance)} {formatCurrency(currency, balance, locale)}
</div> </div>
{balance !== 0 && ( {balance !== 0 && (
<div <div

View File

@@ -15,6 +15,7 @@ import {
getSuggestedReimbursements, getSuggestedReimbursements,
} from '@/lib/balances' } from '@/lib/balances'
import { Metadata } from 'next' import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -26,6 +27,7 @@ export default async function GroupPage({
}: { }: {
params: { groupId: string } params: { groupId: string }
}) { }) {
const t = await getTranslations('Balances')
const group = await cached.getGroup(groupId) const group = await cached.getGroup(groupId)
if (!group) notFound() if (!group) notFound()
@@ -38,10 +40,8 @@ export default async function GroupPage({
<> <>
<Card className="mb-4"> <Card className="mb-4">
<CardHeader> <CardHeader>
<CardTitle>Balances</CardTitle> <CardTitle>{t('title')}</CardTitle>
<CardDescription> <CardDescription>{t('description')}</CardDescription>
This is the amount that each participant paid or was paid for.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<BalancesList <BalancesList
@@ -53,11 +53,8 @@ export default async function GroupPage({
</Card> </Card>
<Card className="mb-4"> <Card className="mb-4">
<CardHeader> <CardHeader>
<CardTitle>Suggested reimbursements</CardTitle> <CardTitle>{t('Reimbursements.title')}</CardTitle>
<CardDescription> <CardDescription>{t('Reimbursements.description')}</CardDescription>
Here are suggestions for optimized reimbursements between
participants.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<ReimbursementList <ReimbursementList

View File

@@ -2,6 +2,7 @@
import { Money } from '@/components/money' import { Money } from '@/components/money'
import { getBalances } from '@/lib/balances' import { getBalances } from '@/lib/balances'
import { useActiveUser } from '@/lib/hooks' import { useActiveUser } from '@/lib/hooks'
import { useTranslations } from 'next-intl'
type Props = { type Props = {
groupId: string groupId: string
@@ -10,6 +11,7 @@ type Props = {
} }
export function ActiveUserBalance({ groupId, currency, expense }: Props) { export function ActiveUserBalance({ groupId, currency, expense }: Props) {
const t = useTranslations('ExpenseCard')
const activeUserId = useActiveUser(groupId) const activeUserId = useActiveUser(groupId)
if (activeUserId === null || activeUserId === '' || activeUserId === 'None') { if (activeUserId === null || activeUserId === '' || activeUserId === 'None') {
return null return null
@@ -33,7 +35,7 @@ export function ActiveUserBalance({ groupId, currency, expense }: Props) {
} }
fmtBalance = ( fmtBalance = (
<> <>
Your balance:{' '} {t('yourBalance')}{' '}
<Money {...{ currency, amount: balance.total }} bold colored /> <Money {...{ currency, amount: balance.total }} bold colored />
{balanceDetail} {balanceDetail}
</> </>

View File

@@ -12,7 +12,6 @@ import {
import { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
DrawerDescription,
DrawerFooter, DrawerFooter,
DrawerHeader, DrawerHeader,
DrawerTitle, DrawerTitle,
@@ -22,6 +21,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { getGroup } from '@/lib/api' import { getGroup } from '@/lib/api'
import { useMediaQuery } from '@/lib/hooks' import { useMediaQuery } from '@/lib/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTranslations } from 'next-intl'
import { ComponentProps, useEffect, useState } from 'react' import { ComponentProps, useEffect, useState } from 'react'
type Props = { type Props = {
@@ -29,6 +29,7 @@ type Props = {
} }
export function ActiveUserModal({ group }: Props) { export function ActiveUserModal({ group }: Props) {
const t = useTranslations('Expenses.ActiveUserModal')
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const isDesktop = useMediaQuery('(min-width: 768px)') const isDesktop = useMediaQuery('(min-width: 768px)')
@@ -52,16 +53,13 @@ export function ActiveUserModal({ group }: Props) {
<Dialog open={open} onOpenChange={updateOpen}> <Dialog open={open} onOpenChange={updateOpen}>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Who are you?</DialogTitle> <DialogTitle>{t('title')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('description')}</DialogDescription>
Tell us which participant you are to let us customize how the
information is displayed.
</DialogDescription>
</DialogHeader> </DialogHeader>
<ActiveUserForm group={group} close={() => setOpen(false)} /> <ActiveUserForm group={group} close={() => setOpen(false)} />
<DialogFooter className="sm:justify-center"> <DialogFooter className="sm:justify-center">
<p className="text-sm text-center text-muted-foreground"> <p className="text-sm text-center text-muted-foreground">
This setting can be changed later in the group settings. {t('footer')}
</p> </p>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -73,11 +71,8 @@ export function ActiveUserModal({ group }: Props) {
<Drawer open={open} onOpenChange={updateOpen}> <Drawer open={open} onOpenChange={updateOpen}>
<DrawerContent> <DrawerContent>
<DrawerHeader className="text-left"> <DrawerHeader className="text-left">
<DrawerTitle>Who are you?</DrawerTitle> <DrawerTitle>{t('title')}</DrawerTitle>
<DrawerDescription> <DialogDescription>{t('description')}</DialogDescription>
Tell us which participant you are to let us customize how the
information is displayed.
</DrawerDescription>
</DrawerHeader> </DrawerHeader>
<ActiveUserForm <ActiveUserForm
className="px-4" className="px-4"
@@ -86,7 +81,7 @@ export function ActiveUserModal({ group }: Props) {
/> />
<DrawerFooter className="pt-2"> <DrawerFooter className="pt-2">
<p className="text-sm text-center text-muted-foreground"> <p className="text-sm text-center text-muted-foreground">
This setting can be changed later in the group settings. {t('footer')}
</p> </p>
</DrawerFooter> </DrawerFooter>
</DrawerContent> </DrawerContent>
@@ -99,6 +94,7 @@ function ActiveUserForm({
close, close,
className, className,
}: ComponentProps<'form'> & { group: Props['group']; close: () => void }) { }: ComponentProps<'form'> & { group: Props['group']; close: () => void }) {
const t = useTranslations('Expenses.ActiveUserModal')
const [selected, setSelected] = useState('None') const [selected, setSelected] = useState('None')
return ( return (
@@ -115,7 +111,7 @@ function ActiveUserForm({
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="none" id="none" /> <RadioGroupItem value="none" id="none" />
<Label htmlFor="none" className="italic font-normal flex-1"> <Label htmlFor="none" className="italic font-normal flex-1">
I dont want to select anyone {t('nobody')}
</Label> </Label>
</div> </div>
{group.participants.map((participant) => ( {group.participants.map((participant) => (
@@ -128,7 +124,7 @@ function ActiveUserForm({
))} ))}
</div> </div>
</RadioGroup> </RadioGroup>
<Button type="submit">Save changes</Button> <Button type="submit">{t('save')}</Button>
</form> </form>
) )
} }

View File

@@ -29,6 +29,7 @@ import { useMediaQuery } from '@/lib/hooks'
import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils' import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
import { Category } from '@prisma/client' import { Category } from '@prisma/client'
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react' import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { getImageData, usePresignedUpload } from 'next-s3-upload' import { getImageData, usePresignedUpload } from 'next-s3-upload'
import Image from 'next/image' import Image from 'next/image'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
@@ -47,6 +48,8 @@ export function CreateFromReceiptButton({
groupCurrency, groupCurrency,
categories, categories,
}: Props) { }: Props) {
const locale = useLocale()
const t = useTranslations('CreateFromReceipt')
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false)
const { uploadToS3, FileInput, openFileDialog } = usePresignedUpload() const { uploadToS3, FileInput, openFileDialog } = usePresignedUpload()
const { toast } = useToast() const { toast } = useToast()
@@ -60,10 +63,11 @@ export function CreateFromReceiptButton({
const handleFileChange = async (file: File) => { const handleFileChange = async (file: File) => {
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
toast({ toast({
title: 'The file is too big', title: t('TooBigToast.title'),
description: `The maximum file size you can upload is ${formatFileSize( description: t('TooBigToast.description', {
MAX_FILE_SIZE, maxSize: formatFileSize(MAX_FILE_SIZE, locale),
)}. Yours is ${formatFileSize(file.size)}.`, size: formatFileSize(file.size, locale),
}),
variant: 'destructive', variant: 'destructive',
}) })
return return
@@ -82,13 +86,15 @@ export function CreateFromReceiptButton({
} catch (err) { } catch (err) {
console.error(err) console.error(err)
toast({ toast({
title: 'Error while uploading document', title: t('ErrorToast.title'),
description: description: t('ErrorToast.description'),
'Something wrong happened when uploading the document. Please retry later or select a different file.',
variant: 'destructive', variant: 'destructive',
action: ( action: (
<ToastAction altText="Retry" onClick={() => upload()}> <ToastAction
Retry altText={t('ErrorToast.retry')}
onClick={() => upload()}
>
{t('ErrorToast.retry')}
</ToastAction> </ToastAction>
), ),
}) })
@@ -114,26 +120,23 @@ export function CreateFromReceiptButton({
<Button <Button
size="icon" size="icon"
variant="secondary" variant="secondary"
title="Create expense from receipt" title={t('Dialog.triggerTitle')}
> >
<Receipt className="w-4 h-4" /> <Receipt className="w-4 h-4" />
</Button> </Button>
} }
title={ title={
<> <>
<span>Create from receipt</span> <span>{t('Dialog.title')}</span>
<Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600"> <Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600">
Beta Beta
</Badge> </Badge>
</> </>
} }
description={<>Extract the expense information from a receipt photo.</>} description={<>{t('Dialog.description')}</>}
> >
<div className="prose prose-sm dark:prose-invert"> <div className="prose prose-sm dark:prose-invert">
<p> <p>{t('Dialog.body')}</p>
Upload the photo of a receipt, and well scan it to extract the
expense information if we can.
</p>
<div> <div>
<FileInput <FileInput
onChange={handleFileChange} onChange={handleFileChange}
@@ -161,16 +164,16 @@ export function CreateFromReceiptButton({
</div> </div>
) : ( ) : (
<span className="text-xs sm:text-sm text-muted-foreground"> <span className="text-xs sm:text-sm text-muted-foreground">
Select image {t('Dialog.selectImage')}
</span> </span>
)} )}
</Button> </Button>
<div className="col-span-2"> <div className="col-span-2">
<strong>Title:</strong> <strong>{t('Dialog.titleLabel')}</strong>
<div>{receiptInfo ? receiptInfo.title ?? <Unknown /> : '…'}</div> <div>{receiptInfo ? receiptInfo.title ?? <Unknown /> : '…'}</div>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<strong>Category:</strong> <strong>{t('Dialog.categoryLabel')}</strong>
<div> <div>
{receiptInfo ? ( {receiptInfo ? (
receiptInfoCategory ? ( receiptInfoCategory ? (
@@ -194,11 +197,17 @@ export function CreateFromReceiptButton({
</div> </div>
</div> </div>
<div> <div>
<strong>Amount:</strong> <strong>{t('Dialog.amountLabel')}</strong>
<div> <div>
{receiptInfo ? ( {receiptInfo ? (
receiptInfo.amount ? ( receiptInfo.amount ? (
<>{formatCurrency(groupCurrency, receiptInfo.amount)}</> <>
{formatCurrency(
groupCurrency,
receiptInfo.amount,
locale,
)}
</>
) : ( ) : (
<Unknown /> <Unknown />
) )
@@ -208,13 +217,15 @@ export function CreateFromReceiptButton({
</div> </div>
</div> </div>
<div> <div>
<strong>Date:</strong> <strong>{t('Dialog.dateLabel')}</strong>
<div> <div>
{receiptInfo ? ( {receiptInfo ? (
receiptInfo.date ? ( receiptInfo.date ? (
formatDate(new Date(`${receiptInfo?.date}T12:00:00.000Z`), { formatDate(
dateStyle: 'medium', new Date(`${receiptInfo?.date}T12:00:00.000Z`),
}) locale,
{ dateStyle: 'medium' },
)
) : ( ) : (
<Unknown /> <Unknown />
) )
@@ -225,7 +236,7 @@ export function CreateFromReceiptButton({
</div> </div>
</div> </div>
</div> </div>
<p>Youll be able to edit the expense information next.</p> <p>{t('Dialog.editNext')}</p>
<div className="text-center"> <div className="text-center">
<Button <Button
disabled={pending || !receiptInfo} disabled={pending || !receiptInfo}
@@ -244,7 +255,7 @@ export function CreateFromReceiptButton({
) )
}} }}
> >
Continue {t('Dialog.continue')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -253,10 +264,11 @@ export function CreateFromReceiptButton({
} }
function Unknown() { function Unknown() {
const t = useTranslations('CreateFromReceipt')
return ( return (
<div className="flex gap-1 items-center text-muted-foreground"> <div className="flex gap-1 items-center text-muted-foreground">
<FileQuestion className="w-4 h-4" /> <FileQuestion className="w-4 h-4" />
<em>Unknown</em> <em>{t('unknown')}</em>
</div> </div>
) )
} }

View File

@@ -5,18 +5,40 @@ import { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api' import { getGroupExpenses } from '@/lib/api'
import { cn, formatCurrency, formatDate } from '@/lib/utils' import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { ChevronRight } from 'lucide-react' import { ChevronRight } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Fragment } from 'react' import { Fragment } from 'react'
type Expense = Awaited<ReturnType<typeof getGroupExpenses>>[number]
function Participants({ expense }: { expense: Expense }) {
const t = useTranslations('ExpenseCard')
const key = expense.amount > 0 ? 'paidBy' : 'receivedBy'
const paidFor = expense.paidFor.map((paidFor, index) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>{paidFor.participant.name}</strong>
</Fragment>
))
const participants = t.rich(key, {
strong: (chunks) => <strong>{chunks}</strong>,
paidBy: expense.paidBy.name,
paidFor: () => paidFor,
forCount: expense.paidFor.length,
})
return <>{participants}</>
}
type Props = { type Props = {
expense: Awaited<ReturnType<typeof getGroupExpenses>>[number] expense: Expense
currency: string currency: string
groupId: string groupId: string
} }
export function ExpenseCard({ expense, currency, groupId }: Props) { export function ExpenseCard({ expense, currency, groupId }: Props) {
const router = useRouter() const router = useRouter()
const locale = useLocale()
return ( return (
<div <div
@@ -38,14 +60,7 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
{expense.title} {expense.title}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{expense.amount > 0 ? 'Paid by ' : 'Received by '} <Participants expense={expense} />
<strong>{expense.paidBy.name}</strong> for{' '}
{expense.paidFor.map((paidFor, index) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>{paidFor.participant.name}</strong>
</Fragment>
))}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
<ActiveUserBalance {...{ groupId, currency, expense }} /> <ActiveUserBalance {...{ groupId, currency, expense }} />
@@ -58,10 +73,10 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
expense.isReimbursement ? 'italic' : 'font-bold', expense.isReimbursement ? 'italic' : 'font-bold',
)} )}
> >
{formatCurrency(currency, expense.amount)} {formatCurrency(currency, expense.amount, locale)}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{formatDate(expense.expenseDate, { dateStyle: 'medium' })} {formatDate(expense.expenseDate, locale, { dateStyle: 'medium' })}
</div> </div>
</div> </div>
<Button <Button

View File

@@ -7,6 +7,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { normalizeString } from '@/lib/utils' import { normalizeString } from '@/lib/utils'
import { Participant } from '@prisma/client' import { Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs' import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useInView } from 'react-intersection-observer' import { useInView } from 'react-intersection-observer'
@@ -24,13 +25,13 @@ type Props = {
} }
const EXPENSE_GROUPS = { const EXPENSE_GROUPS = {
UPCOMING: 'Upcoming', UPCOMING: 'upcoming',
THIS_WEEK: 'This week', THIS_WEEK: 'thisWeek',
EARLIER_THIS_MONTH: 'Earlier this month', EARLIER_THIS_MONTH: 'earlierThisMonth',
LAST_MONTH: 'Last month', LAST_MONTH: 'lastMonth',
EARLIER_THIS_YEAR: 'Earlier this year', EARLIER_THIS_YEAR: 'earlierThisYear',
LAST_YEAR: 'Last year', LAST_YEAR: 'lastYear',
OLDER: 'Older', OLDER: 'older',
} }
function getExpenseGroup(date: Dayjs, today: Dayjs) { function getExpenseGroup(date: Dayjs, today: Dayjs) {
@@ -76,6 +77,7 @@ export function ExpenseList({
const [isFetching, setIsFetching] = useState(false) const [isFetching, setIsFetching] = useState(false)
const [expenses, setExpenses] = useState(expensesFirstPage) const [expenses, setExpenses] = useState(expensesFirstPage)
const { ref, inView } = useInView() const { ref, inView } = useInView()
const t = useTranslations('Expenses')
useEffect(() => { useEffect(() => {
const activeUser = localStorage.getItem('newGroup-activeUser') const activeUser = localStorage.getItem('newGroup-activeUser')
@@ -155,7 +157,7 @@ export function ExpenseList({
'text-muted-foreground text-xs pl-4 sm:pl-6 py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]' 'text-muted-foreground text-xs pl-4 sm:pl-6 py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
} }
> >
{expenseGroup} {t(`Groups.${expenseGroup}`)}
</div> </div>
{groupExpenses.map((expense) => ( {groupExpenses.map((expense) => (
<ExpenseCard <ExpenseCard
@@ -187,10 +189,10 @@ export function ExpenseList({
</> </>
) : ( ) : (
<p className="px-6 text-sm py-6"> <p className="px-6 text-sm py-6">
Your group doesnt contain any expense yet.{' '} {t('noExpenses')}{' '}
<Button variant="link" asChild className="-m-4"> <Button variant="link" asChild className="-m-4">
<Link href={`/groups/${groupId}/expenses/create`}> <Link href={`/groups/${groupId}/expenses/create`}>
Create the first one {t('createFirst')}
</Link> </Link>
</Button> </Button>
</p> </p>

View File

@@ -19,6 +19,7 @@ import {
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { Download, Plus } from 'lucide-react' import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next' import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import Link from 'next/link' import Link from 'next/link'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { Suspense } from 'react' import { Suspense } from 'react'
@@ -34,6 +35,7 @@ export default async function GroupExpensesPage({
}: { }: {
params: { groupId: string } params: { groupId: string }
}) { }) {
const t = await getTranslations('Expenses')
const group = await cached.getGroup(groupId) const group = await cached.getGroup(groupId)
if (!group) notFound() if (!group) notFound()
@@ -44,10 +46,8 @@ export default async function GroupExpensesPage({
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0"> <Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0">
<div className="flex flex-1"> <div className="flex flex-1">
<CardHeader className="flex-1 p-4 sm:p-6"> <CardHeader className="flex-1 p-4 sm:p-6">
<CardTitle>Expenses</CardTitle> <CardTitle>{t('title')}</CardTitle>
<CardDescription> <CardDescription>{t('description')}</CardDescription>
Here are the expenses that you created for your group.
</CardDescription>
</CardHeader> </CardHeader>
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2"> <CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
<Button variant="secondary" size="icon" asChild> <Button variant="secondary" size="icon" asChild>
@@ -55,7 +55,7 @@ export default async function GroupExpensesPage({
prefetch={false} prefetch={false}
href={`/groups/${groupId}/expenses/export/json`} href={`/groups/${groupId}/expenses/export/json`}
target="_blank" target="_blank"
title="Export to JSON" title={t('exportJson')}
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Link> </Link>
@@ -70,7 +70,7 @@ export default async function GroupExpensesPage({
<Button asChild size="icon"> <Button asChild size="icon">
<Link <Link
href={`/groups/${groupId}/expenses/create`} href={`/groups/${groupId}/expenses/create`}
title="Create expense" title={t('create')}
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Link> </Link>

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useTranslations } from 'next-intl'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
type Props = { type Props = {
@@ -7,6 +8,7 @@ type Props = {
} }
export function GroupTabs({ groupId }: Props) { export function GroupTabs({ groupId }: Props) {
const t = useTranslations()
const pathname = usePathname() const pathname = usePathname()
const value = const value =
pathname.replace(/\/groups\/[^\/]+\/([^/]+).*/, '$1') || 'expenses' pathname.replace(/\/groups\/[^\/]+\/([^/]+).*/, '$1') || 'expenses'
@@ -21,11 +23,11 @@ export function GroupTabs({ groupId }: Props) {
}} }}
> >
<TabsList> <TabsList>
<TabsTrigger value="expenses">Expenses</TabsTrigger> <TabsTrigger value="expenses">{t('Expenses.title')}</TabsTrigger>
<TabsTrigger value="balances">Balances</TabsTrigger> <TabsTrigger value="balances">{t('Balances.title')}</TabsTrigger>
<TabsTrigger value="stats">Stats</TabsTrigger> <TabsTrigger value="stats">{t('Stats.title')}</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger> <TabsTrigger value="activity">{t('Activity.title')}</TabsTrigger>
<TabsTrigger value="edit">Settings</TabsTrigger> <TabsTrigger value="edit">{t('Settings.title')}</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
) )

View File

@@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button'
import { Reimbursement } from '@/lib/balances' import { Reimbursement } from '@/lib/balances'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { Participant } from '@prisma/client' import { Participant } from '@prisma/client'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
type Props = { type Props = {
@@ -17,12 +18,10 @@ export function ReimbursementList({
currency, currency,
groupId, groupId,
}: Props) { }: Props) {
const locale = useLocale()
const t = useTranslations('Balances.Reimbursements')
if (reimbursements.length === 0) { if (reimbursements.length === 0) {
return ( return <p className="px-6 text-sm pb-6">{t('noImbursements')}</p>
<p className="px-6 text-sm pb-6">
It looks like your group doesnt need any reimbursement 😁
</p>
)
} }
const getParticipant = (id: string) => participants.find((p) => p.id === id) const getParticipant = (id: string) => participants.find((p) => p.id === id)
@@ -32,18 +31,21 @@ export function ReimbursementList({
<div className="border-t px-6 py-4 flex justify-between" key={index}> <div className="border-t px-6 py-4 flex justify-between" key={index}>
<div className="flex flex-col gap-1 items-start sm:flex-row sm:items-baseline sm:gap-4"> <div className="flex flex-col gap-1 items-start sm:flex-row sm:items-baseline sm:gap-4">
<div> <div>
<strong>{getParticipant(reimbursement.from)?.name}</strong> owes{' '} {t.rich('owes', {
<strong>{getParticipant(reimbursement.to)?.name}</strong> from: getParticipant(reimbursement.from)?.name,
to: getParticipant(reimbursement.to)?.name,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</div> </div>
<Button variant="link" asChild className="-mx-4 -my-3"> <Button variant="link" asChild className="-mx-4 -my-3">
<Link <Link
href={`/groups/${groupId}/expenses/create?reimbursement=yes&from=${reimbursement.from}&to=${reimbursement.to}&amount=${reimbursement.amount}`} href={`/groups/${groupId}/expenses/create?reimbursement=yes&from=${reimbursement.from}&to=${reimbursement.to}&amount=${reimbursement.amount}`}
> >
Mark as paid {t('markAsPaid')}
</Link> </Link>
</Button> </Button>
</div> </div>
<div>{formatCurrency(currency, reimbursement.amount)}</div> <div>{formatCurrency(currency, reimbursement.amount, locale)}</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -11,27 +11,26 @@ import {
import { useBaseUrl } from '@/lib/hooks' import { useBaseUrl } from '@/lib/hooks'
import { Group } from '@prisma/client' import { Group } from '@prisma/client'
import { Share } from 'lucide-react' import { Share } from 'lucide-react'
import { useTranslations } from 'next-intl'
type Props = { type Props = {
group: Group group: Group
} }
export function ShareButton({ group }: Props) { export function ShareButton({ group }: Props) {
const t = useTranslations('Share')
const baseUrl = useBaseUrl() const baseUrl = useBaseUrl()
const url = baseUrl && `${baseUrl}/groups/${group.id}/expenses?ref=share` const url = baseUrl && `${baseUrl}/groups/${group.id}/expenses?ref=share`
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button title="Share" size="icon" className="flex-shrink-0"> <Button title={t('title')} size="icon" className="flex-shrink-0">
<Share className="w-4 h-4" /> <Share className="w-4 h-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="[&_p]:text-sm flex flex-col gap-3"> <PopoverContent align="end" className="[&_p]:text-sm flex flex-col gap-3">
<p> <p>{t('description')}</p>
For other participants to see the group and add expenses, share its
URL with them.
</p>
{url && ( {url && (
<div className="flex gap-2"> <div className="flex gap-2">
<Input className="flex-1" defaultValue={url} readOnly /> <Input className="flex-1" defaultValue={url} readOnly />
@@ -43,8 +42,7 @@ export function ShareButton({ group }: Props) {
</div> </div>
)} )}
<p> <p>
<strong>Warning!</strong> Every person with the group URL will be able <strong>{t('warning')}</strong> {t('warningHelp')}
to see and edit expenses. Share with caution!
</p> </p>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@@ -10,6 +10,7 @@ import {
import { getGroupExpenses } from '@/lib/api' import { getGroupExpenses } from '@/lib/api'
import { getTotalGroupSpending } from '@/lib/totals' import { getTotalGroupSpending } from '@/lib/totals'
import { Metadata } from 'next' import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -21,6 +22,7 @@ export default async function TotalsPage({
}: { }: {
params: { groupId: string } params: { groupId: string }
}) { }) {
const t = await getTranslations('Stats')
const group = await cached.getGroup(groupId) const group = await cached.getGroup(groupId)
if (!group) notFound() if (!group) notFound()
@@ -31,10 +33,8 @@ export default async function TotalsPage({
<> <>
<Card className="mb-4"> <Card className="mb-4">
<CardHeader> <CardHeader>
<CardTitle>Totals</CardTitle> <CardTitle>{t('Totals.title')}</CardTitle>
<CardDescription> <CardDescription>{t('Totals.description')}</CardDescription>
Spending summary of the entire group.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col space-y-4"> <CardContent className="flex flex-col space-y-4">
<Totals <Totals

View File

@@ -1,4 +1,5 @@
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
type Props = { type Props = {
totalGroupSpendings: number totalGroupSpendings: number
@@ -6,12 +7,14 @@ type Props = {
} }
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) { export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
const balance = totalGroupSpendings < 0 ? 'earnings' : 'spendings' const locale = useLocale()
const t = useTranslations('Stats.Totals')
const balance = totalGroupSpendings < 0 ? 'groupEarnings' : 'groupSpendings'
return ( return (
<div> <div>
<div className="text-muted-foreground">Total group {balance}</div> <div className="text-muted-foreground">{t(balance)}</div>
<div className="text-lg"> <div className="text-lg">
{formatCurrency(currency, Math.abs(totalGroupSpendings))} {formatCurrency(currency, Math.abs(totalGroupSpendings), locale)}
</div> </div>
</div> </div>
) )

View File

@@ -2,6 +2,7 @@
import { getGroup, getGroupExpenses } from '@/lib/api' import { getGroup, getGroupExpenses } from '@/lib/api'
import { getTotalActiveUserShare } from '@/lib/totals' import { getTotalActiveUserShare } from '@/lib/totals'
import { cn, formatCurrency } from '@/lib/utils' import { cn, formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
type Props = { type Props = {
@@ -10,6 +11,8 @@ type Props = {
} }
export function TotalsYourShare({ group, expenses }: Props) { export function TotalsYourShare({ group, expenses }: Props) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')
const [activeUser, setActiveUser] = useState('') const [activeUser, setActiveUser] = useState('')
useEffect(() => { useEffect(() => {
@@ -25,14 +28,14 @@ export function TotalsYourShare({ group, expenses }: Props) {
return ( return (
<div> <div>
<div className="text-muted-foreground">Your total share</div> <div className="text-muted-foreground">{t('yourShare')}</div>
<div <div
className={cn( className={cn(
'text-lg', 'text-lg',
totalActiveUserShare < 0 ? 'text-green-600' : 'text-red-600', totalActiveUserShare < 0 ? 'text-green-600' : 'text-red-600',
)} )}
> >
{formatCurrency(currency, Math.abs(totalActiveUserShare))} {formatCurrency(currency, Math.abs(totalActiveUserShare), locale)}
</div> </div>
</div> </div>
) )

View File

@@ -3,6 +3,7 @@ import { getGroup, getGroupExpenses } from '@/lib/api'
import { useActiveUser } from '@/lib/hooks' import { useActiveUser } from '@/lib/hooks'
import { getTotalActiveUserPaidFor } from '@/lib/totals' import { getTotalActiveUserPaidFor } from '@/lib/totals'
import { cn, formatCurrency } from '@/lib/utils' import { cn, formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
type Props = { type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>> group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
@@ -10,6 +11,8 @@ type Props = {
} }
export function TotalsYourSpendings({ group, expenses }: Props) { export function TotalsYourSpendings({ group, expenses }: Props) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')
const activeUser = useActiveUser(group.id) const activeUser = useActiveUser(group.id)
const totalYourSpendings = const totalYourSpendings =
@@ -17,11 +20,11 @@ export function TotalsYourSpendings({ group, expenses }: Props) {
? 0 ? 0
: getTotalActiveUserPaidFor(activeUser, expenses) : getTotalActiveUserPaidFor(activeUser, expenses)
const currency = group.currency const currency = group.currency
const balance = totalYourSpendings < 0 ? 'earnings' : 'spendings' const balance = totalYourSpendings < 0 ? 'yourEarnings' : 'yourSpendings'
return ( return (
<div> <div>
<div className="text-muted-foreground">Your total {balance}</div> <div className="text-muted-foreground">{t(balance)}</div>
<div <div
className={cn( className={cn(
@@ -29,7 +32,7 @@ export function TotalsYourSpendings({ group, expenses }: Props) {
totalYourSpendings < 0 ? 'text-green-600' : 'text-red-600', totalYourSpendings < 0 ? 'text-green-600' : 'text-red-600',
)} )}
> >
{formatCurrency(currency, Math.abs(totalYourSpendings))} {formatCurrency(currency, Math.abs(totalYourSpendings), locale)}
</div> </div>
</div> </div>
) )

View File

@@ -9,6 +9,7 @@ import {
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { useMediaQuery } from '@/lib/hooks' import { useMediaQuery } from '@/lib/hooks'
import { Loader2, Plus } from 'lucide-react' import { Loader2, Plus } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useState } from 'react' import { useState } from 'react'
type Props = { type Props = {
@@ -16,6 +17,7 @@ type Props = {
} }
export function AddGroupByUrlButton({ reload }: Props) { export function AddGroupByUrlButton({ reload }: Props) {
const t = useTranslations('Groups.AddByURL')
const isDesktop = useMediaQuery('(min-width: 640px)') const isDesktop = useMediaQuery('(min-width: 640px)')
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [error, setError] = useState(false) const [error, setError] = useState(false)
@@ -27,18 +29,15 @@ export function AddGroupByUrlButton({ reload }: Props) {
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="secondary"> <Button variant="secondary">
{/* <Plus className="w-4 h-4 mr-2" /> */} {/* <Plus className="w-4 h-4 mr-2" /> */}
<>Add by URL</> {t('button')}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
align={isDesktop ? 'end' : 'start'} align={isDesktop ? 'end' : 'start'}
className="[&_p]:text-sm flex flex-col gap-3" className="[&_p]:text-sm flex flex-col gap-3"
> >
<h3 className="font-bold">Add a group by URL</h3> <h3 className="font-bold">{t('title')}</h3>
<p> <p>{t('description')}</p>
If a group was shared with you, you can paste its URL here to add it
to your list.
</p>
<form <form
className="flex gap-2" className="flex gap-2"
onSubmit={async (event) => { onSubmit={async (event) => {
@@ -80,11 +79,7 @@ export function AddGroupByUrlButton({ reload }: Props) {
)} )}
</Button> </Button>
</form> </form>
{error && ( {error && <p className="text-destructive">{t('error')}</p>}
<p className="text-destructive">
Oops, we are not able to find the group from the URL you provided
</p>
)}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )

View File

@@ -1,13 +1,15 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
export default function NotFound() { export default function NotFound() {
const t = useTranslations('Groups.NotFound')
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p>This group does not exist.</p> <p>{t('text')}</p>
<p> <p>
<Button asChild variant="secondary"> <Button asChild variant="secondary">
<Link href="/groups">Go to recently visited groups</Link> <Link href="/groups">{t('link')}</Link>
</Button> </Button>
</p> </p>
</div> </div>

View File

@@ -23,6 +23,7 @@ import { ToastAction } from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { StarFilledIcon } from '@radix-ui/react-icons' import { StarFilledIcon } from '@radix-ui/react-icons'
import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react' import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { SetStateAction } from 'react' import { SetStateAction } from 'react'
@@ -37,7 +38,9 @@ export function RecentGroupListCard({
setState: (state: SetStateAction<RecentGroupsState>) => void setState: (state: SetStateAction<RecentGroupsState>) => void
}) { }) {
const router = useRouter() const router = useRouter()
const locale = useLocale()
const toast = useToast() const toast = useToast()
const t = useTranslations('Groups')
const details = const details =
state.status === 'complete' state.status === 'complete'
@@ -118,12 +121,11 @@ export function RecentGroupListCard({
groups: state.groups.filter((g) => g.id !== group.id), groups: state.groups.filter((g) => g.id !== group.id),
}) })
toast.toast({ toast.toast({
title: 'Group has been removed', title: t('RecentRemovedToast.title'),
description: description: t('RecentRemovedToast.description'),
'The group was removed from your recent groups list.',
action: ( action: (
<ToastAction <ToastAction
altText="Undo group removal" altText={t('RecentRemovedToast.undoAlt')}
onClick={() => { onClick={() => {
saveRecentGroup(group) saveRecentGroup(group)
setState({ setState({
@@ -132,13 +134,13 @@ export function RecentGroupListCard({
}) })
}} }}
> >
Undo {t('RecentRemovedToast.undo')}
</ToastAction> </ToastAction>
), ),
}) })
}} }}
> >
Remove from recent groups {t('removeRecent')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={(event) => { onClick={(event) => {
@@ -152,7 +154,7 @@ export function RecentGroupListCard({
refreshGroupsFromStorage() refreshGroupsFromStorage()
}} }}
> >
{isArchived ? <>Unarchive group</> : <>Archive group</>} {t(isArchived ? 'unarchive' : 'archive')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -168,7 +170,7 @@ export function RecentGroupListCard({
<div className="flex items-center"> <div className="flex items-center">
<Calendar className="w-3 h-3 inline mx-1" /> <Calendar className="w-3 h-3 inline mx-1" />
<span> <span>
{new Date(details.createdAt).toLocaleDateString('en-US', { {new Date(details.createdAt).toLocaleDateString(locale, {
dateStyle: 'medium', dateStyle: 'medium',
})} })}
</span> </span>

View File

@@ -10,6 +10,7 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { getGroups } from '@/lib/api' import { getGroups } from '@/lib/api'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { PropsWithChildren, SetStateAction, useEffect, useState } from 'react' import { PropsWithChildren, SetStateAction, useEffect, useState } from 'react'
import { RecentGroupListCard } from './recent-group-list-card' import { RecentGroupListCard } from './recent-group-list-card'
@@ -53,6 +54,7 @@ function sortGroups(
} }
export function RecentGroupList() { export function RecentGroupList() {
const t = useTranslations('Groups')
const [state, setState] = useState<RecentGroupsState>({ status: 'pending' }) const [state, setState] = useState<RecentGroupsState>({ status: 'pending' })
function loadGroups() { function loadGroups() {
@@ -84,8 +86,8 @@ export function RecentGroupList() {
return ( return (
<GroupsPage reload={loadGroups}> <GroupsPage reload={loadGroups}>
<p> <p>
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" /> Loading <Loader2 className="w-4 m-4 mr-2 inline animate-spin" />{' '}
recent groups {t('loadingRecent')}
</p> </p>
</GroupsPage> </GroupsPage>
) )
@@ -95,12 +97,12 @@ export function RecentGroupList() {
return ( return (
<GroupsPage reload={loadGroups}> <GroupsPage reload={loadGroups}>
<div className="text-sm space-y-2"> <div className="text-sm space-y-2">
<p>You have not visited any group recently.</p> <p>{t('NoRecent.description')}</p>
<p> <p>
<Button variant="link" asChild className="-m-4"> <Button variant="link" asChild className="-m-4">
<Link href={`/groups/create`}>Create one</Link> <Link href={`/groups/create`}>{t('NoRecent.create')}</Link>
</Button>{' '} </Button>{' '}
or ask a friend to send you the link to an existing one. {t('NoRecent.orAsk')}
</p> </p>
</div> </div>
</GroupsPage> </GroupsPage>
@@ -113,7 +115,7 @@ export function RecentGroupList() {
<GroupsPage reload={loadGroups}> <GroupsPage reload={loadGroups}>
{starredGroupInfo.length > 0 && ( {starredGroupInfo.length > 0 && (
<> <>
<h2 className="mb-2">Starred groups</h2> <h2 className="mb-2">{t('starred')}</h2>
<GroupList <GroupList
groups={starredGroupInfo} groups={starredGroupInfo}
state={state} state={state}
@@ -124,14 +126,14 @@ export function RecentGroupList() {
{groupInfo.length > 0 && ( {groupInfo.length > 0 && (
<> <>
<h2 className="mt-6 mb-2">Recent groups</h2> <h2 className="mt-6 mb-2">{t('recent')}</h2>
<GroupList groups={groupInfo} state={state} setState={setState} /> <GroupList groups={groupInfo} state={state} setState={setState} />
</> </>
)} )}
{archivedGroupInfo.length > 0 && ( {archivedGroupInfo.length > 0 && (
<> <>
<h2 className="mt-6 mb-2 opacity-50">Archived groups</h2> <h2 className="mt-6 mb-2 opacity-50">{t('archived')}</h2>
<div className="opacity-50"> <div className="opacity-50">
<GroupList <GroupList
groups={archivedGroupInfo} groups={archivedGroupInfo}
@@ -172,18 +174,19 @@ function GroupsPage({
children, children,
reload, reload,
}: PropsWithChildren<{ reload: () => void }>) { }: PropsWithChildren<{ reload: () => void }>) {
const t = useTranslations('Groups')
return ( return (
<> <>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="font-bold text-2xl flex-1"> <h1 className="font-bold text-2xl flex-1">
<Link href="/groups">My groups</Link> <Link href="/groups">{t('myGroups')}</Link>
</h1> </h1>
<div className="flex gap-2"> <div className="flex gap-2">
<AddGroupByUrlButton reload={reload} /> <AddGroupByUrlButton reload={reload} />
<Button asChild> <Button asChild>
<Link href="/groups/create"> <Link href="/groups/create">
{/* <Plus className="w-4 h-4 mr-2" /> */} {/* <Plus className="w-4 h-4 mr-2" /> */}
<>Create</> {t('create')}
</Link> </Link>
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { ApplePwaSplash } from '@/app/apple-pwa-splash' import { ApplePwaSplash } from '@/app/apple-pwa-splash'
import { LocaleSwitcher } from '@/components/locale-switcher'
import { ProgressBar } from '@/components/progress-bar' import { ProgressBar } from '@/components/progress-bar'
import { ThemeProvider } from '@/components/theme-provider' import { ThemeProvider } from '@/components/theme-provider'
import { ThemeToggle } from '@/components/theme-toggle' import { ThemeToggle } from '@/components/theme-toggle'
@@ -6,6 +7,8 @@ import { Button } from '@/components/ui/button'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import type { Metadata, Viewport } from 'next' 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 Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { Suspense } from 'react' import { Suspense } from 'react'
@@ -59,93 +62,109 @@ export const viewport: Viewport = {
themeColor: '#047857', themeColor: '#047857',
} }
export default function RootLayout({ function Content({ children }: { children: React.ReactNode }) {
children, const t = useTranslations()
}: {
children: React.ReactNode
}) {
return ( return (
<html lang="en" suppressHydrationWarning> <>
<ApplePwaSplash icon="/logo-with-text.png" color="#027756" /> <header className="fixed top-0 left-0 right-0 h-16 flex justify-between bg-white dark:bg-gray-950 bg-opacity-50 dark:bg-opacity-50 p-2 border-b backdrop-blur-sm z-50">
<body className="pt-16 min-h-[100dvh] flex flex-col items-stretch bg-slate-50 bg-opacity-30 dark:bg-background"> <Link
<ThemeProvider className="flex items-center gap-2 hover:scale-105 transition-transform"
attribute="class" href="/"
defaultTheme="system"
enableSystem
disableTransitionOnChange
> >
<Suspense> <h1>
<ProgressBar /> <Image
</Suspense> src="/logo-with-text.png"
<header className="fixed top-0 left-0 right-0 h-16 flex justify-between bg-white dark:bg-gray-950 bg-opacity-50 dark:bg-opacity-50 p-2 border-b backdrop-blur-sm z-50"> className="m-1 h-auto w-auto"
<Link width={(35 * 522) / 180}
className="flex items-center gap-2 hover:scale-105 transition-transform" height={35}
href="/" alt="Spliit"
> />
<h1> </h1>
<Image </Link>
src="/logo-with-text.png" <div role="navigation" aria-label="Menu" className="flex">
className="m-1 h-auto w-auto" <ul className="flex items-center text-sm">
width={(35 * 522) / 180} <li>
height={35} <Button variant="ghost" asChild className="-my-3 text-primary">
alt="Spliit" <Link href="/groups">{t('Header.groups')}</Link>
/> </Button>
</h1> </li>
<li>
<LocaleSwitcher />
</li>
<li>
<ThemeToggle />
</li>
</ul>
</div>
</header>
<div className="flex-1 flex flex-col">{children}</div>
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col sm:flex-row sm:justify-between gap-4 text-xs [&_a]:underline">
<div className="flex flex-col space-y-2">
<div className="sm:text-lg font-semibold text-base flex space-x-2 items-center">
<Link className="flex items-center gap-2" href="/">
<Image
src="/logo-with-text.png"
className="m-1 h-auto w-auto"
width={(35 * 522) / 180}
height={35}
alt="Spliit"
/>
</Link> </Link>
<div role="navigation" aria-label="Menu" className="flex"> </div>
<ul className="flex items-center text-sm"> <div className="flex flex-col space-y a--no-underline-text-white">
<li> <span>{t('Footer.madeIn')}</span>
<Button <span>
variant="ghost" {t.rich('Footer.builtBy', {
asChild author: (txt) => (
className="-my-3 text-primary"
>
<Link href="/groups">Groups</Link>
</Button>
</li>
<li>
<ThemeToggle />
</li>
</ul>
</div>
</header>
<div className="flex-1 flex flex-col">{children}</div>
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col sm:flex-row sm:justify-between gap-4 text-xs [&_a]:underline">
<div className="flex flex-col space-y-2">
<div className="sm:text-lg font-semibold text-base flex space-x-2 items-center">
<Link className="flex items-center gap-2" href="/">
<Image
src="/logo-with-text.png"
className="m-1 h-auto w-auto"
width={(35 * 522) / 180}
height={35}
alt="Spliit"
/>
</Link>
</div>
<div className="flex flex-col space-y a--no-underline-text-white">
<span>Made in Montréal, Québec 🇨🇦</span>
<span>
Built by{' '}
<a href="https://scastiel.dev" target="_blank" rel="noopener"> <a href="https://scastiel.dev" target="_blank" rel="noopener">
Sebastien Castiel {txt}
</a>{' '} </a>
and{' '} ),
source: (txt) => (
<a <a
href="https://github.com/spliit-app/spliit/graphs/contributors" href="https://github.com/spliit-app/spliit/graphs/contributors"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
> >
contributors {txt}
</a> </a>
</span> ),
</div> })}
</div> </span>
</footer> </div>
<Toaster /> </div>
</ThemeProvider> </footer>
<Toaster />
</>
)
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const locale = await getLocale()
const messages = await getMessages()
return (
<html lang={locale} suppressHydrationWarning>
<ApplePwaSplash icon="/logo-with-text.png" color="#027756" />
<body className="pt-16 min-h-[100dvh] flex flex-col items-stretch bg-slate-50 bg-opacity-30 dark:bg-background">
<NextIntlClientProvider messages={messages}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Suspense>
<ProgressBar />
</Suspense>
<Content>{children}</Content>
</ThemeProvider>
</NextIntlClientProvider>
</body> </body>
</html> </html>
) )

View File

@@ -17,6 +17,7 @@ import {
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { useMediaQuery } from '@/lib/hooks' import { useMediaQuery } from '@/lib/hooks'
import { Category } from '@prisma/client' import { Category } from '@prisma/client'
import { useTranslations } from 'next-intl'
import { forwardRef, useEffect, useState } from 'react' import { forwardRef, useEffect, useState } from 'react'
type Props = { type Props = {
@@ -100,6 +101,7 @@ function CategoryCommand({
categories: Category[] categories: Category[]
onValueChange: (categoryId: Category['id']) => void onValueChange: (categoryId: Category['id']) => void
}) { }) {
const t = useTranslations('Categories')
const categoriesByGroup = categories.reduce<Record<string, Category[]>>( const categoriesByGroup = categories.reduce<Record<string, Category[]>>(
(acc, category) => ({ (acc, category) => ({
...acc, ...acc,
@@ -110,16 +112,18 @@ function CategoryCommand({
return ( return (
<Command> <Command>
<CommandInput placeholder="Search category..." className="text-base" /> <CommandInput placeholder={t('search')} className="text-base" />
<CommandEmpty>No category found.</CommandEmpty> <CommandEmpty>{t('noCategory')}</CommandEmpty>
<div className="w-full max-h-[300px] overflow-y-auto"> <div className="w-full max-h-[300px] overflow-y-auto">
{Object.entries(categoriesByGroup).map( {Object.entries(categoriesByGroup).map(
([group, groupCategories], index) => ( ([group, groupCategories], index) => (
<CommandGroup key={index} heading={group}> <CommandGroup key={index} heading={t(`${group}.heading`)}>
{groupCategories.map((category) => ( {groupCategories.map((category) => (
<CommandItem <CommandItem
key={category.id} key={category.id}
value={`${category.id} ${category.grouping} ${category.name}`} value={`${category.id} ${t(
`${category.grouping}.heading`,
)} ${t(`${category.grouping}.${category.name}`)}`}
onSelect={(currentValue) => { onSelect={(currentValue) => {
const id = Number(currentValue.split(' ')[0]) const id = Number(currentValue.split(' ')[0])
onValueChange(id) onValueChange(id)
@@ -169,10 +173,11 @@ const CategoryButton = forwardRef<HTMLButtonElement, CategoryButtonProps>(
CategoryButton.displayName = 'CategoryButton' CategoryButton.displayName = 'CategoryButton'
function CategoryLabel({ category }: { category: Category }) { function CategoryLabel({ category }: { category: Category }) {
const t = useTranslations('Categories')
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CategoryIcon category={category} className="w-4 h-4" /> <CategoryIcon category={category} className="w-4 h-4" />
{category.name} {t(`${category.grouping}.${category.name}`)}
</div> </div>
) )
} }

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { AsyncButton } from './async-button' import { AsyncButton } from './async-button'
import { Button } from './ui/button' import { Button } from './ui/button'
import { import {
@@ -14,20 +15,18 @@ import {
} from './ui/dialog' } from './ui/dialog'
export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) { export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
const t = useTranslations('ExpenseForm.DeletePopup')
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="destructive"> <Button variant="destructive">
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Delete {t('label')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogTitle>Delete this expense?</DialogTitle> <DialogTitle>{t('title')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('description')}</DialogDescription>
Do you really want to delete this expense? This action is
irreversible.
</DialogDescription>
<DialogFooter className="flex flex-col gap-2"> <DialogFooter className="flex flex-col gap-2">
<AsyncButton <AsyncButton
type="button" type="button"
@@ -35,10 +34,10 @@ export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
loadingContent="Deleting…" loadingContent="Deleting…"
action={onDelete} action={onDelete}
> >
Yes {t('yes')}
</AsyncButton> </AsyncButton>
<DialogClose asChild> <DialogClose asChild>
<Button variant={'secondary'}>Cancel</Button> <Button variant={'secondary'}>{t('cancel')}</Button>
</DialogClose> </DialogClose>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -19,6 +19,7 @@ import { randomId } from '@/lib/api'
import { ExpenseFormValues } from '@/lib/schemas' import { ExpenseFormValues } from '@/lib/schemas'
import { formatFileSize } from '@/lib/utils' import { formatFileSize } from '@/lib/utils'
import { Loader2, Plus, Trash, X } from 'lucide-react' import { Loader2, Plus, Trash, X } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { getImageData, usePresignedUpload } from 'next-s3-upload' import { getImageData, usePresignedUpload } from 'next-s3-upload'
import Image from 'next/image' import Image from 'next/image'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -31,6 +32,8 @@ type Props = {
const MAX_FILE_SIZE = 5 * 1024 ** 2 const MAX_FILE_SIZE = 5 * 1024 ** 2
export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) { export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) {
const locale = useLocale()
const t = useTranslations('ExpenseDocumentsInput')
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false)
const { FileInput, openFileDialog, uploadToS3 } = usePresignedUpload() // use presigned uploads to addtionally support providers other than AWS const { FileInput, openFileDialog, uploadToS3 } = usePresignedUpload() // use presigned uploads to addtionally support providers other than AWS
const { toast } = useToast() const { toast } = useToast()
@@ -38,10 +41,11 @@ export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) {
const handleFileChange = async (file: File) => { const handleFileChange = async (file: File) => {
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
toast({ toast({
title: 'The file is too big', title: t('TooBigToast.title'),
description: `The maximum file size you can upload is ${formatFileSize( description: t('TooBigToast.description', {
MAX_FILE_SIZE, maxSize: formatFileSize(MAX_FILE_SIZE, locale),
)}. Yours is ${formatFileSize(file.size)}.`, size: formatFileSize(file.size, locale),
}),
variant: 'destructive', variant: 'destructive',
}) })
return return
@@ -57,13 +61,15 @@ export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) {
} catch (err) { } catch (err) {
console.error(err) console.error(err)
toast({ toast({
title: 'Error while uploading document', title: t('ErrorToast.title'),
description: description: t('ErrorToast.description'),
'Something wrong happened when uploading the document. Please retry later or select a different file.',
variant: 'destructive', variant: 'destructive',
action: ( action: (
<ToastAction altText="Retry" onClick={() => upload()}> <ToastAction
Retry altText={t('ErrorToast.retry')}
onClick={() => upload()}
>
{t('ErrorToast.retry')}
</ToastAction> </ToastAction>
), ),
}) })

View File

@@ -44,6 +44,7 @@ import {
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { Save } from 'lucide-react' import { Save } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -71,9 +72,6 @@ const enforceCurrencyPattern = (value: string) =>
.replace(/#/, '.') // change back # to dot .replace(/#/, '.') // change back # to dot
.replace(/[^-\d.]/g, '') // remove all non-numeric characters .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 getDefaultSplittingOptions = (group: Props['group']) => {
const defaultValue = { const defaultValue = {
splitMode: 'EVENLY' as const, splitMode: 'EVENLY' as const,
@@ -154,6 +152,7 @@ export function ExpenseForm({
onDelete, onDelete,
runtimeFeatureFlags, runtimeFeatureFlags,
}: Props) { }: Props) {
const t = useTranslations('ExpenseForm')
const isCreate = expense === undefined const isCreate = expense === undefined
const searchParams = useSearchParams() const searchParams = useSearchParams()
const getSelectedPayer = (field?: { value: string }) => { const getSelectedPayer = (field?: { value: string }) => {
@@ -249,7 +248,7 @@ export function ExpenseForm({
Set<string> Set<string>
>(new Set()) >(new Set())
const sExpense = isIncome ? 'income' : 'expense' const sExpense = isIncome ? 'Income' : 'Expense'
const sPaid = isIncome ? 'received' : 'paid' const sPaid = isIncome ? 'received' : 'paid'
useEffect(() => { useEffect(() => {
@@ -322,7 +321,9 @@ export function ExpenseForm({
<form onSubmit={form.handleSubmit(submit)}> <form onSubmit={form.handleSubmit(submit)}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{(isCreate ? 'Create ' : 'Edit ') + sExpense}</CardTitle> <CardTitle>
{t(`${sExpense}.${isCreate ? 'create' : 'edit'}`)}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid sm:grid-cols-2 gap-6"> <CardContent className="grid sm:grid-cols-2 gap-6">
<FormField <FormField
@@ -330,10 +331,10 @@ export function ExpenseForm({
name="title" name="title"
render={({ field }) => ( render={({ field }) => (
<FormItem className=""> <FormItem className="">
<FormLabel>{capitalize(sExpense)} title</FormLabel> <FormLabel>{t(`${sExpense}.TitleField.label`)}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="Monday evening restaurant" placeholder={t(`${sExpense}.TitleField.placeholder`)}
className="text-base" className="text-base"
{...field} {...field}
onBlur={async () => { onBlur={async () => {
@@ -350,7 +351,7 @@ export function ExpenseForm({
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter a description for the {sExpense}. {t(`${sExpense}.TitleField.description`)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -362,7 +363,7 @@ export function ExpenseForm({
name="expenseDate" name="expenseDate"
render={({ field }) => ( render={({ field }) => (
<FormItem className="sm:order-1"> <FormItem className="sm:order-1">
<FormLabel>{capitalize(sExpense)} date</FormLabel> <FormLabel>{t(`${sExpense}.DateField.label`)}</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="date-base" className="date-base"
@@ -374,7 +375,7 @@ export function ExpenseForm({
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter the date the {sExpense} was {sPaid}. {t(`${sExpense}.DateField.description`)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -386,7 +387,7 @@ export function ExpenseForm({
name="amount" name="amount"
render={({ field: { onChange, ...field } }) => ( render={({ field: { onChange, ...field } }) => (
<FormItem className="sm:order-3"> <FormItem className="sm:order-3">
<FormLabel>Amount</FormLabel> <FormLabel>{t('amountField.label')}</FormLabel>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span>{group.currency}</span> <span>{group.currency}</span>
<FormControl> <FormControl>
@@ -426,7 +427,9 @@ export function ExpenseForm({
/> />
</FormControl> </FormControl>
<div> <div>
<FormLabel>This is a reimbursement</FormLabel> <FormLabel>
{t('isReimbursementField.label')}
</FormLabel>
</div> </div>
</FormItem> </FormItem>
)} )}
@@ -441,7 +444,7 @@ export function ExpenseForm({
name="category" name="category"
render={({ field }) => ( render={({ field }) => (
<FormItem className="order-3 sm:order-2"> <FormItem className="order-3 sm:order-2">
<FormLabel>Category</FormLabel> <FormLabel>{t('categoryField.label')}</FormLabel>
<CategorySelector <CategorySelector
categories={categories} categories={categories}
defaultValue={ defaultValue={
@@ -451,7 +454,7 @@ export function ExpenseForm({
isLoading={isCategoryLoading} isLoading={isCategoryLoading}
/> />
<FormDescription> <FormDescription>
Select the {sExpense} category. {t(`${sExpense}.categoryFieldDescription`)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -463,7 +466,7 @@ export function ExpenseForm({
name="paidBy" name="paidBy"
render={({ field }) => ( render={({ field }) => (
<FormItem className="sm:order-5"> <FormItem className="sm:order-5">
<FormLabel>{capitalize(sPaid)} by</FormLabel> <FormLabel>{t(`${sExpense}.paidByField.label`)}</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={getSelectedPayer(field)} defaultValue={getSelectedPayer(field)}
@@ -480,7 +483,7 @@ export function ExpenseForm({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <FormDescription>
Select the participant who {sPaid} the {sExpense}. {t(`${sExpense}.paidByField.description`)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -491,7 +494,7 @@ export function ExpenseForm({
name="notes" name="notes"
render={({ field }) => ( render={({ field }) => (
<FormItem className="sm:order-6"> <FormItem className="sm:order-6">
<FormLabel>Notes</FormLabel> <FormLabel>{t('notesField.label')}</FormLabel>
<FormControl> <FormControl>
<Textarea className="text-base" {...field} /> <Textarea className="text-base" {...field} />
</FormControl> </FormControl>
@@ -504,7 +507,7 @@ export function ExpenseForm({
<Card className="mt-4"> <Card className="mt-4">
<CardHeader> <CardHeader>
<CardTitle className="flex justify-between"> <CardTitle className="flex justify-between">
<span>{capitalize(sPaid)} for</span> <span>{t(`${sExpense}.paidFor.title`)}</span>
<Button <Button
variant="link" variant="link"
type="button" type="button"
@@ -530,14 +533,14 @@ export function ExpenseForm({
> >
{form.getValues().paidFor.length === {form.getValues().paidFor.length ===
group.participants.length ? ( group.participants.length ? (
<>Select none</> <>{t('selectNone')}</>
) : ( ) : (
<>Select all</> <>{t('selectAll')}</>
)} )}
</Button> </Button>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Select who the {sExpense} was {sPaid} for. {t(`${sExpense}.paidFor.description`)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -602,7 +605,9 @@ export function ExpenseForm({
})} })}
> >
{match(form.getValues().splitMode) {match(form.getValues().splitMode)
.with('BY_SHARES', () => <>share(s)</>) .with('BY_SHARES', () => (
<>{t('shares')}</>
))
.with('BY_PERCENTAGE', () => <>%</>) .with('BY_PERCENTAGE', () => <>%</>)
.with('BY_AMOUNT', () => ( .with('BY_AMOUNT', () => (
<>{group.currency}</> <>{group.currency}</>
@@ -700,7 +705,7 @@ export function ExpenseForm({
> >
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="link" className="-mx-4"> <Button variant="link" className="-mx-4">
Advanced splitting options {t('advancedOptions')}
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
@@ -710,7 +715,7 @@ export function ExpenseForm({
name="splitMode" name="splitMode"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Split mode</FormLabel> <FormLabel>{t('SplitModeField.label')}</FormLabel>
<FormControl> <FormControl>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
@@ -726,21 +731,23 @@ export function ExpenseForm({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="EVENLY">Evenly</SelectItem> <SelectItem value="EVENLY">
{t('SplitModeField.evenly')}
</SelectItem>
<SelectItem value="BY_SHARES"> <SelectItem value="BY_SHARES">
Unevenly By shares {t('SplitModeField.byShares')}
</SelectItem> </SelectItem>
<SelectItem value="BY_PERCENTAGE"> <SelectItem value="BY_PERCENTAGE">
Unevenly By percentage {t('SplitModeField.byPercentage')}
</SelectItem> </SelectItem>
<SelectItem value="BY_AMOUNT"> <SelectItem value="BY_AMOUNT">
Unevenly By amount {t('SplitModeField.byAmount')}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Select how to split the {sExpense}. {t(`${sExpense}.splitModeDescription`)}
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
@@ -758,7 +765,7 @@ export function ExpenseForm({
</FormControl> </FormControl>
<div> <div>
<FormLabel> <FormLabel>
Save as default splitting options {t('SplitModeField.saveAsDefault')}
</FormLabel> </FormLabel>
</div> </div>
</FormItem> </FormItem>
@@ -774,10 +781,10 @@ export function ExpenseForm({
<Card className="mt-4"> <Card className="mt-4">
<CardHeader> <CardHeader>
<CardTitle className="flex justify-between"> <CardTitle className="flex justify-between">
<span>Attach documents</span> <span>{t('attachDocuments')}</span>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
See and attach receipts to the {sExpense}. {t(`${sExpense}.attachDescription`)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -796,11 +803,9 @@ export function ExpenseForm({
)} )}
<div className="flex mt-4 gap-2"> <div className="flex mt-4 gap-2">
<SubmitButton <SubmitButton loadingContent={t(isCreate ? 'creating' : 'saving')}>
loadingContent={isCreate ? <>Creating</> : <>Saving</>}
>
<Save className="w-4 h-4 mr-2" /> <Save className="w-4 h-4 mr-2" />
{isCreate ? <>Create</> : <>Save</>} {t(isCreate ? 'create' : 'save')}
</SubmitButton> </SubmitButton>
{!isCreate && onDelete && ( {!isCreate && onDelete && (
<DeletePopup <DeletePopup
@@ -808,7 +813,7 @@ export function ExpenseForm({
></DeletePopup> ></DeletePopup>
)} )}
<Button variant="ghost" asChild> <Button variant="ghost" asChild>
<Link href={`/groups/${group.id}`}>Cancel</Link> <Link href={`/groups/${group.id}`}>{t('cancel')}</Link>
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -35,6 +35,7 @@ import { getGroup } from '@/lib/api'
import { GroupFormValues, groupFormSchema } from '@/lib/schemas' import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { Save, Trash2 } from 'lucide-react' import { Save, Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form' import { useFieldArray, useForm } from 'react-hook-form'
@@ -53,6 +54,7 @@ export function GroupForm({
onSubmit, onSubmit,
protectedParticipantIds = [], protectedParticipantIds = [],
}: Props) { }: Props) {
const t = useTranslations('GroupForm')
const form = useForm<GroupFormValues>({ const form = useForm<GroupFormValues>({
resolver: zodResolver(groupFormSchema), resolver: zodResolver(groupFormSchema),
defaultValues: group defaultValues: group
@@ -64,7 +66,11 @@ export function GroupForm({
: { : {
name: '', name: '',
currency: '', currency: '',
participants: [{ name: 'John' }, { name: 'Jane' }, { name: 'Jack' }], participants: [
{ name: t('Participants.John') },
{ name: t('Participants.Jane') },
{ name: t('Participants.Jack') },
],
}, },
}) })
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
@@ -79,10 +85,10 @@ export function GroupForm({
const currentActiveUser = const currentActiveUser =
fields.find( fields.find(
(f) => f.id === localStorage.getItem(`${group?.id}-activeUser`), (f) => f.id === localStorage.getItem(`${group?.id}-activeUser`),
)?.name || 'None' )?.name || t('Settings.ActiveUserField.none')
setActiveUser(currentActiveUser) setActiveUser(currentActiveUser)
} }
}, [activeUser, fields, group?.id]) }, [t, activeUser, fields, group?.id])
const updateActiveUser = () => { const updateActiveUser = () => {
if (!activeUser) return if (!activeUser) return
@@ -111,7 +117,7 @@ export function GroupForm({
> >
<Card className="mb-4"> <Card className="mb-4">
<CardHeader> <CardHeader>
<CardTitle>Group information</CardTitle> <CardTitle>{t('title')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField <FormField
@@ -119,16 +125,16 @@ export function GroupForm({
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Group name</FormLabel> <FormLabel>{t('NameField.label')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-base" className="text-base"
placeholder="Summer vacations" placeholder={t('NameField.placeholder')}
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter a name for your group. {t('NameField.description')}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -140,17 +146,17 @@ export function GroupForm({
name="currency" name="currency"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Currency symbol</FormLabel> <FormLabel>{t('CurrencyField.label')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-base" className="text-base"
placeholder="$, €, £…" placeholder={t('CurrencyField.placeholder')}
max={5} max={5}
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Well use it to display amounts. {t('CurrencyField.description')}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -161,10 +167,8 @@ export function GroupForm({
<Card className="mb-4"> <Card className="mb-4">
<CardHeader> <CardHeader>
<CardTitle>Participants</CardTitle> <CardTitle>{t('Participants.title')}</CardTitle>
<CardDescription> <CardDescription>{t('Participants.description')}</CardDescription>
Enter the name for each participant
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
@@ -183,7 +187,7 @@ export function GroupForm({
<Input <Input
className="text-base" className="text-base"
{...field} {...field}
placeholder="New" placeholder={t('Participants.new')}
/> />
{item.id && {item.id &&
protectedParticipantIds.includes(item.id) ? ( protectedParticipantIds.includes(item.id) ? (
@@ -203,8 +207,7 @@ export function GroupForm({
align="end" align="end"
className="text-sm" className="text-sm"
> >
This participant is part of expenses, and can {t('Participants.protectedParticipant')}
not be removed.
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
) : ( ) : (
@@ -236,24 +239,21 @@ export function GroupForm({
}} }}
type="button" type="button"
> >
Add participant {t('Participants.add')}
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>
<Card className="mb-4"> <Card className="mb-4">
<CardHeader> <CardHeader>
<CardTitle>Local settings</CardTitle> <CardTitle>{t('Settings.title')}</CardTitle>
<CardDescription> <CardDescription>{t('Settings.description')}</CardDescription>
These settings are set per-device, and are used to customize your
experience.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid sm:grid-cols-2 gap-4"> <div className="grid sm:grid-cols-2 gap-4">
{activeUser !== null && ( {activeUser !== null && (
<FormItem> <FormItem>
<FormLabel>Active user</FormLabel> <FormLabel>{t('Settings.ActiveUserField.label')}</FormLabel>
<FormControl> <FormControl>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
@@ -262,10 +262,17 @@ export function GroupForm({
defaultValue={activeUser} defaultValue={activeUser}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a participant" /> <SelectValue
placeholder={t(
'Settings.ActiveUserField.placeholder',
)}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{[{ name: 'None' }, ...form.watch('participants')] {[
{ name: t('Settings.ActiveUserField.none') },
...form.watch('participants'),
]
.filter((item) => item.name.length > 0) .filter((item) => item.name.length > 0)
.map(({ name }) => ( .map(({ name }) => (
<SelectItem key={name} value={name}> <SelectItem key={name} value={name}>
@@ -276,7 +283,7 @@ export function GroupForm({
</Select> </Select>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
User used as default for paying expenses. {t('Settings.ActiveUserField.description')}
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
@@ -286,14 +293,15 @@ export function GroupForm({
<div className="flex mt-4 gap-2"> <div className="flex mt-4 gap-2">
<SubmitButton <SubmitButton
loadingContent={group ? 'Saving' : 'Creating'} loadingContent={t(group ? 'Settings.saving' : 'Settings.creating')}
onClick={updateActiveUser} onClick={updateActiveUser}
> >
<Save className="w-4 h-4 mr-2" /> {group ? <>Save</> : <> Create</>} <Save className="w-4 h-4 mr-2" />{' '}
{t(group ? 'Settings.save' : 'Settings.create')}
</SubmitButton> </SubmitButton>
{!group && ( {!group && (
<Button variant="ghost" asChild> <Button variant="ghost" asChild>
<Link href="/groups">Cancel</Link> <Link href="/groups">{t('Settings.cancel')}</Link>
</Button> </Button>
)} )}
</div> </div>

View File

@@ -0,0 +1,33 @@
'use client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { locales } from '@/i18n'
import { setUserLocale } from '@/lib/locale'
import { useLocale, useTranslations } from 'next-intl'
export function LocaleSwitcher() {
const t = useTranslations('Locale')
const locale = useLocale()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" asChild className="-my-3 text-primary">
<span>{t(locale)}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{locales.map((locale) => (
<DropdownMenuItem key={locale} onClick={() => setUserLocale(locale)}>
{t(locale)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { cn, formatCurrency } from '@/lib/utils' import { cn, formatCurrency } from '@/lib/utils'
import { useLocale } from 'next-intl'
type Props = { type Props = {
currency: string currency: string
@@ -14,6 +15,7 @@ export function Money({
bold = false, bold = false,
colored = false, colored = false,
}: Props) { }: Props) {
const locale = useLocale()
return ( return (
<span <span
className={cn( className={cn(
@@ -25,7 +27,7 @@ export function Money({
bold && 'font-bold', bold && 'font-bold',
)} )}
> >
{formatCurrency(currency, amount)} {formatCurrency(currency, amount, locale)}
</span> </span>
) )
} }

View File

@@ -12,6 +12,7 @@ import {
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { useMessages } from "next-intl"
const Form = FormProvider const Form = FormProvider
@@ -144,8 +145,18 @@ const FormMessage = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => { >(({ className, children, ...props }, ref) => {
const messages = useMessages()
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children let body
if (error) {
body = String(error?.message)
const translation = (messages.SchemaErrors as any)[body]
if (translation) {
body = translation
}
} else {
body = children
}
if (!body) { if (!body) {
return null return null

View File

@@ -2,6 +2,7 @@ import * as React from 'react'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTranslations } from 'next-intl'
import { Search, XCircle } from 'lucide-react' import { Search, XCircle } from 'lucide-react'
export interface InputProps export interface InputProps
@@ -11,6 +12,7 @@ export interface InputProps
const SearchBar = React.forwardRef<HTMLInputElement, InputProps>( const SearchBar = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, onValueChange, ...props }, ref) => { ({ className, type, onValueChange, ...props }, ref) => {
const t = useTranslations('Expenses')
const [value, _setValue] = React.useState('') const [value, _setValue] = React.useState('')
const setValue = (v: string) => { const setValue = (v: string) => {
@@ -28,7 +30,7 @@ const SearchBar = React.forwardRef<HTMLInputElement, InputProps>(
className, className,
)} )}
ref={ref} ref={ref}
placeholder="Search for an expense…" placeholder={t("searchPlaceholder")}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
{...props} {...props}

16
src/i18n.ts Normal file
View File

@@ -0,0 +1,16 @@
import { getRequestConfig } from 'next-intl/server'
import { getUserLocale } from './lib/locale'
export const locales = ['en-US', 'fi'] as const
export type Locale = (typeof locales)[number]
export type Locales = ReadonlyArray<Locale>
export const defaultLocale: Locale = 'en-US'
export default getRequestConfig(async () => {
const locale = await getUserLocale()
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
}
})

42
src/lib/locale.ts Normal file
View File

@@ -0,0 +1,42 @@
'use server'
import { Locale, Locales, defaultLocale, locales } from '@/i18n'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import { cookies, headers } from 'next/headers'
const COOKIE_NAME = 'NEXT_LOCALE'
function getAcceptLanguageLocale(requestHeaders: Headers, locales: Locales) {
let locale
const languages = new Negotiator({
headers: {
'accept-language': requestHeaders.get('accept-language') || undefined,
},
}).languages()
try {
locale = match(languages, locales, defaultLocale)
} catch (e) {
// invalid language
}
return locale
}
export async function getUserLocale() {
let locale
// Prio 1: use existing cookie
locale = cookies().get(COOKIE_NAME)?.value
// Prio 2: use `accept-language` header
// Prio 3: use default locale
if (!locale) {
locale = getAcceptLanguageLocale(headers(), locales)
}
return locale
}
export async function setUserLocale(locale: Locale) {
cookies().set(COOKIE_NAME, locale)
}

View File

@@ -3,22 +3,13 @@ import * as z from 'zod'
export const groupFormSchema = z export const groupFormSchema = z
.object({ .object({
name: z name: z.string().min(2, 'min2').max(50, 'max50'),
.string() currency: z.string().min(1, 'min1').max(5, 'max5'),
.min(2, 'Enter at least two characters.')
.max(50, 'Enter at most 50 characters.'),
currency: z
.string()
.min(1, 'Enter at least one character.')
.max(5, 'Enter at most five characters.'),
participants: z participants: z
.array( .array(
z.object({ z.object({
id: z.string().optional(), id: z.string().optional(),
name: z name: z.string().min(2, 'min2').max(50, 'max50'),
.string()
.min(2, 'Enter at least two characters.')
.max(50, 'Enter at most 50 characters.'),
}), }),
) )
.min(1), .min(1),
@@ -29,7 +20,7 @@ export const groupFormSchema = z
if (otherParticipant.name === participant.name) { if (otherParticipant.name === participant.name) {
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: 'custom',
message: 'Another participant already has this name.', message: 'duplicateParticipantName',
path: ['participants', i, 'name'], path: ['participants', i, 'name'],
}) })
} }
@@ -42,9 +33,7 @@ export type GroupFormValues = z.infer<typeof groupFormSchema>
export const expenseFormSchema = z export const expenseFormSchema = z
.object({ .object({
expenseDate: z.coerce.date(), expenseDate: z.coerce.date(),
title: z title: z.string({ required_error: 'titleRequired' }).min(2, 'min2'),
.string({ required_error: 'Please enter a title.' })
.min(2, 'Enter at least two characters.'),
category: z.coerce.number().default(0), category: z.coerce.number().default(0),
amount: z amount: z
.union( .union(
@@ -55,19 +44,16 @@ export const expenseFormSchema = z
if (Number.isNaN(valueAsNumber)) if (Number.isNaN(valueAsNumber))
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'Invalid number.', message: 'invalidNumber',
}) })
return Math.round(valueAsNumber * 100) return Math.round(valueAsNumber * 100)
}), }),
], ],
{ required_error: 'You must enter an amount.' }, { required_error: 'amountRequired' },
) )
.refine((amount) => amount != 1, 'The amount must not be zero.') .refine((amount) => amount != 1, 'amountNotZero')
.refine( .refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
(amount) => amount <= 10_000_000_00, paidBy: z.string({ required_error: 'paidByRequired' }),
'The amount must be lower than 10,000,000.',
),
paidBy: z.string({ required_error: 'You must select a participant.' }),
paidFor: z paidFor: z
.array( .array(
z.object({ z.object({
@@ -80,14 +66,14 @@ export const expenseFormSchema = z
if (Number.isNaN(valueAsNumber)) if (Number.isNaN(valueAsNumber))
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'Invalid number.', message: 'invalidNumber',
}) })
return Math.round(valueAsNumber * 100) return Math.round(valueAsNumber * 100)
}), }),
]), ]),
}), }),
) )
.min(1, 'The expense must be paid for at least one participant.') .min(1, 'paidForMin1')
.superRefine((paidFor, ctx) => { .superRefine((paidFor, ctx) => {
let sum = 0 let sum = 0
for (const { shares } of paidFor) { for (const { shares } of paidFor) {
@@ -95,7 +81,7 @@ export const expenseFormSchema = z
if (shares < 1) { if (shares < 1) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'All shares must be higher than 0.', message: 'noZeroShares',
}) })
} }
} }
@@ -138,7 +124,7 @@ export const expenseFormSchema = z
: `${((sum - expense.amount) / 100).toFixed(2)} surplus` : `${((sum - expense.amount) / 100).toFixed(2)} surplus`
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: `Sum of amounts must equal the expense amount (${detail}).`, message: 'amountSum',
path: ['paidFor'], path: ['paidFor'],
}) })
} }
@@ -152,7 +138,7 @@ export const expenseFormSchema = z
: `${((sum - 10000) / 100).toFixed(0)}% surplus` : `${((sum - 10000) / 100).toFixed(0)}% surplus`
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: `Sum of percentages must equal 100 (${detail})`, message: 'percentageSum',
path: ['paidFor'], path: ['paidFor'],
}) })
} }

View File

@@ -15,9 +15,10 @@ export type DateTimeStyle = NonNullable<
>['dateStyle'] >['dateStyle']
export function formatDate( export function formatDate(
date: Date, date: Date,
locale: string,
options: { dateStyle?: DateTimeStyle; timeStyle?: DateTimeStyle } = {}, options: { dateStyle?: DateTimeStyle; timeStyle?: DateTimeStyle } = {},
) { ) {
return date.toLocaleString('en-GB', { return date.toLocaleString(locale, {
...options, ...options,
timeZone: 'UTC', timeZone: 'UTC',
}) })
@@ -27,18 +28,25 @@ export function formatCategoryForAIPrompt(category: Category) {
return `"${category.grouping}/${category.name}" (ID: ${category.id})` return `"${category.grouping}/${category.name}" (ID: ${category.id})`
} }
export function formatCurrency(currency: string, amount: number) { export function formatCurrency(
const format = new Intl.NumberFormat('en-US', { currency: string,
amount: number,
locale: string,
) {
const format = new Intl.NumberFormat(locale, {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
style: 'currency',
// '€' will be placed in correct position
currency: 'EUR',
}) })
const formattedAmount = format.format(amount / 100) const formattedAmount = format.format(amount / 100)
return `${currency} ${formattedAmount}` return formattedAmount.replace('€', currency)
} }
export function formatFileSize(size: number) { export function formatFileSize(size: number, locale: string) {
const formatNumber = (num: number) => const formatNumber = (num: number) =>
num.toLocaleString('en-US', { num.toLocaleString(locale, {
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: 1, maximumFractionDigits: 1,
}) })