9 Commits
1.8.1 ... 1.9.0

Author SHA1 Message Date
Sebastien Castiel
5dfe03b3f1 Make header buttons smaller (#191) 2024-08-02 12:22:39 -04:00
Sebastien Castiel
26bed11116 Update Next.js + Npm audit fix (#190)
* Audit fix

* Upade Next
2024-08-02 12:18:49 -04:00
Chris Johnston
972bb9dadb add group information field to group settings and Information tab (#164)
* add group information field to group and Information tab to display

* add breaks to info page

* Improve UX

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-08-02 12:03:36 -04:00
Tuomas Jaakola
4f5e124ff0 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>
2024-08-02 11:26:23 -04:00
Miska Pajukangas
c392c06b39 feat: add auto-balancing for the amount edit (#173)
* feat: add auto-balancing for the amount edit

this implementation allocates the rest of the total
to participants, whose rows have yet not been edited.

* fix: reset already edited on total amount change
2024-08-02 11:04:21 -04:00
Laszlo Makk
002e867bc4 Make recalculation stable across repayments in suggested reimbursements (#179)
* suggested reimbursements: make recalculation stable across repayments

Previously, after a group participant executed a suggested reimbursement, rerunning getSuggestedReimbursements() could return a completely new list of suggestions.

With this change, getSuggestedReimbursements() should now be stable:
if it returns a graph with n edges, and then a repayment is made according to one of those edges, when called again, it should now return the same graph but with that one edge removed.

The trick is that the main logic in getSuggestedReimbursements() does not rely on balancesArray being sorted based on .total values, only that the array gets partitioned into participants with credit first and then participants with debt last. After a repayment is made, re-sorting based on .total values would result in a new order hence new suggestions, but sorting based on usernames/participantIds should be unaffected.

fixes https://github.com/spliit-app/spliit/issues/178

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-08-02 10:58:46 -04:00
Tuomas Jaakola
9b8f716a6a Use unique name for postgres container (#171) 2024-08-02 10:58:33 -04:00
Tuomas Jaakola
853f1791d2 recent-groups-page.tsx removed (#182) 2024-08-02 10:57:39 -04:00
Sergio Behrends
7145cb6f30 Increase fuzzines of search results (#187)
* Introduce normalizeString fn

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-08-02 10:57:18 -04:00
48 changed files with 7098 additions and 1125 deletions

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

@@ -0,0 +1,383 @@
{
"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."
},
"InformationField": {
"label": "Group information",
"placeholder": "What information is relevant to group participants?"
},
"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"
}
},
"Information": {
"title": "Information",
"description": "Use this place to add any information that can be relevant to the group participants.",
"empty": "No group information yet."
},
"Settings": {
"title": "Settings"
},
"Locale": {
"en-US": "English",
"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"
}
}
}

383
messages/fi.json Normal file
View File

@@ -0,0 +1,383 @@
{
"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."
},
"InformationField": {
"label": "Ryhmän tiedot",
"placeholder": "Mitkä tiedot ovat merkityksellisiä ryhmän osallistujille?"
},
"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"
}
},
"Information": {
"title": "Tiedot",
"description": "Käytä tätä paikkaa lisätäksesi kaikki tiedot, joilla voi olla merkitystä ryhmän osallistujille.",
"empty": "Ryhmätietoja ei vielä ole."
},
"Settings": {
"title": "Asetukset"
},
"Locale": {
"en-US": "English",
"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)

6245
package-lock.json generated

File diff suppressed because it is too large Load Diff

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",
"next": "^14.2.3", "negotiator": "^0.6.3",
"next": "^14.2.5",
"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

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Group" ADD COLUMN "information" TEXT;

View File

@@ -14,6 +14,7 @@ datasource db {
model Group { model Group {
id String @id id String @id
name String name String
information String? @db.Text
currency String @default("$") currency String @default("$")
participants Participant[] participants Participant[]
expenses Expense[] expenses Expense[]

View File

@@ -1,4 +1,4 @@
result=$(docker ps | grep postgres) result=$(docker ps | grep spliit-db)
if [ $? -eq 0 ]; if [ $? -eq 0 ];
then then
echo "postgres is already running, doing nothing" echo "postgres is already running, doing nothing"
@@ -6,6 +6,6 @@ else
echo "postgres is not running, starting it" echo "postgres is not running, starting it"
docker rm postgres --force docker rm postgres --force
mkdir -p postgres-data mkdir -p postgres-data
docker run --name postgres -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v "/$(pwd)/postgres-data:/var/lib/postgresql/data" postgres docker run --name spliit-db -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v "/$(pwd)/postgres-data:/var/lib/postgresql/data" postgres
sleep 5 # Wait for postgres to start sleep 5 # Wait for postgres to start
fi fi

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

@@ -4,8 +4,10 @@ import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense-
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { SearchBar } from '@/components/ui/search-bar' import { SearchBar } from '@/components/ui/search-bar'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
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'
@@ -23,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) {
@@ -75,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')
@@ -134,13 +137,15 @@ export function ExpenseList({
return expenses.length > 0 ? ( return expenses.length > 0 ? (
<> <>
<SearchBar onValueChange={(value) => setSearchText(value)} /> <SearchBar
onValueChange={(value) => setSearchText(normalizeString(value))}
/>
{Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => { {Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
let groupExpenses = groupedExpensesByDate[expenseGroup] let groupExpenses = groupedExpensesByDate[expenseGroup]
if (!groupExpenses) return null if (!groupExpenses) return null
groupExpenses = groupExpenses.filter(({ title }) => groupExpenses = groupExpenses.filter(({ title }) =>
title.toLowerCase().includes(searchText.toLowerCase()), normalizeString(title).includes(searchText),
) )
if (groupExpenses.length === 0) return null if (groupExpenses.length === 0) return null
@@ -152,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
@@ -184,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,12 @@ 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="information">{t('Information.title')}</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger> <TabsTrigger value="stats">{t('Stats.title')}</TabsTrigger>
<TabsTrigger value="edit">Settings</TabsTrigger> <TabsTrigger value="activity">{t('Activity.title')}</TabsTrigger>
<TabsTrigger value="edit">{t('Settings.title')}</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
) )

View File

@@ -0,0 +1,54 @@
import { cached } from '@/app/cached-functions'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Pencil } from 'lucide-react'
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import Link from 'next/link'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Totals',
}
export default async function InformationPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
const t = await getTranslations('Information')
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>{t('title')}</span>
<Button size="icon" asChild className="-mb-12">
<Link href={`/groups/${groupId}/edit`}>
<Pencil className="w-4 h-4" />
</Link>
</Button>
</CardTitle>
<CardDescription className="mr-12">
{t('description')}
</CardDescription>
</CardHeader>
<CardContent className="prose prose-sm sm:prose-base max-w-full whitespace-break-spaces">
{group.information || (
<p className="text-muted-foreground italic">{t('empty')}</p>
)}
</CardContent>
</Card>
</>
)
}

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,29 +0,0 @@
'use client'
import { AddGroupByUrlButton } from '@/app/groups/add-group-by-url-button'
import { RecentGroupList } from '@/app/groups/recent-group-list'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
export function RecentGroupsPage() {
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="font-bold text-2xl flex-1">
<Link href="/groups">My groups</Link>
</h1>
<div className="flex gap-2">
<AddGroupByUrlButton reload={() => {}} />
<Button asChild>
<Link href="/groups/create">
{/* <Plus className="w-4 h-4 mr-2" /> */}
<>Create</>
</Link>
</Button>
</div>
</div>
<div>
<RecentGroupList />
</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,114 @@ 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
alt="Spliit" variant="ghost"
/> size="sm"
</h1> asChild
className="-my-3 text-primary"
>
<Link href="/groups">{t('Header.groups')}</Link>
</Button>
</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,9 +44,10 @@ 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 { useState } from 'react' import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { match } from 'ts-pattern' import { match } from 'ts-pattern'
import { DeletePopup } from './delete-popup' import { DeletePopup } from './delete-popup'
@@ -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 }) => {
@@ -245,15 +244,86 @@ export function ExpenseForm({
} }
const [isIncome, setIsIncome] = useState(Number(form.getValues().amount) < 0) const [isIncome, setIsIncome] = useState(Number(form.getValues().amount) < 0)
const sExpense = isIncome ? 'income' : 'expense' const [manuallyEditedParticipants, setManuallyEditedParticipants] = useState<
Set<string>
>(new Set())
const sExpense = isIncome ? 'Income' : 'Expense'
const sPaid = isIncome ? 'received' : 'paid' const sPaid = isIncome ? 'received' : 'paid'
useEffect(() => {
setManuallyEditedParticipants(new Set())
const newPaidFor = defaultSplittingOptions.paidFor.map((participant) => ({
...participant,
shares: String(participant.shares) as unknown as number,
}))
form.setValue('paidFor', newPaidFor, { shouldValidate: true })
}, [form.watch('splitMode'), form.watch('amount')])
useEffect(() => {
const totalAmount = Number(form.getValues().amount) || 0
const paidFor = form.getValues().paidFor
const splitMode = form.getValues().splitMode
let newPaidFor = [...paidFor]
if (
splitMode === 'EVENLY' ||
splitMode === 'BY_SHARES' ||
splitMode === 'BY_PERCENTAGE'
) {
return
} else {
// Only auto-balance for split mode 'Unevenly - By amount'
const editedParticipants = Array.from(manuallyEditedParticipants)
let remainingAmount = totalAmount
let remainingParticipants = newPaidFor.length - editedParticipants.length
newPaidFor = newPaidFor.map((participant) => {
if (editedParticipants.includes(participant.participant)) {
const participantShare = Number(participant.shares) || 0
if (splitMode === 'BY_AMOUNT') {
remainingAmount -= participantShare
}
return participant
}
return participant
})
if (remainingParticipants > 0) {
let amountPerRemaining = 0
if (splitMode === 'BY_AMOUNT') {
amountPerRemaining = remainingAmount / remainingParticipants
}
newPaidFor = newPaidFor.map((participant) => {
if (!editedParticipants.includes(participant.participant)) {
return {
...participant,
shares: String(
Number(amountPerRemaining.toFixed(2)),
) as unknown as number,
}
}
return participant
})
}
form.setValue('paidFor', newPaidFor, { shouldValidate: true })
}
}, [
manuallyEditedParticipants,
form.watch('amount'),
form.watch('splitMode'),
])
return ( return (
<Form {...form}> <Form {...form}>
<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
@@ -261,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 () => {
@@ -281,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>
@@ -293,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"
@@ -305,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>
@@ -317,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>
@@ -357,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>
)} )}
@@ -372,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={
@@ -382,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>
@@ -394,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)}
@@ -411,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>
@@ -422,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>
@@ -435,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"
@@ -461,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>
@@ -533,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}</>
@@ -570,7 +644,7 @@ export function ExpenseForm({
participant === id, participant === id,
)?.shares )?.shares
} }
onChange={(event) => onChange={(event) => {
field.onChange( field.onChange(
field.value.map((p) => field.value.map((p) =>
p.participant === id p.participant === id
@@ -584,7 +658,10 @@ export function ExpenseForm({
: p, : p,
), ),
) )
} setManuallyEditedParticipants(
(prev) => new Set(prev).add(id),
)
}}
inputMode={ inputMode={
form.getValues().splitMode === form.getValues().splitMode ===
'BY_AMOUNT' 'BY_AMOUNT'
@@ -628,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>
@@ -638,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) => {
@@ -654,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>
)} )}
@@ -686,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>
@@ -702,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>
@@ -724,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
@@ -736,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,9 +35,11 @@ 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'
import { Textarea } from './ui/textarea'
export type Props = { export type Props = {
group?: NonNullable<Awaited<ReturnType<typeof getGroup>>> group?: NonNullable<Awaited<ReturnType<typeof getGroup>>>
@@ -53,18 +55,25 @@ 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
? { ? {
name: group.name, name: group.name,
information: group.information ?? '',
currency: group.currency, currency: group.currency,
participants: group.participants, participants: group.participants,
} }
: { : {
name: '', name: '',
information: '',
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 +88,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 +120,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 +128,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,31 +149,50 @@ 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>
)} )}
/> />
<div className="col-span-2">
<FormField
control={form.control}
name="information"
render={({ field }) => (
<FormItem>
<FormLabel>{t('InformationField.label')}</FormLabel>
<FormControl>
<Textarea
rows={10}
className="text-base"
{...field}
placeholder={t('InformationField.placeholder')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
<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 +211,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 +231,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 +263,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 +286,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 +307,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 +317,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" size="sm" 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,
}
})

View File

@@ -12,6 +12,7 @@ export async function createGroup(groupFormValues: GroupFormValues) {
data: { data: {
id: randomId(), id: randomId(),
name: groupFormValues.name, name: groupFormValues.name,
information: groupFormValues.information,
currency: groupFormValues.currency, currency: groupFormValues.currency,
participants: { participants: {
createMany: { createMany: {
@@ -226,6 +227,7 @@ export async function updateGroup(
where: { id: groupId }, where: { id: groupId },
data: { data: {
name: groupFormValues.name, name: groupFormValues.name,
information: groupFormValues.information,
currency: groupFormValues.currency, currency: groupFormValues.currency,
participants: { participants: {
deleteMany: existingGroup.participants.filter( deleteMany: existingGroup.participants.filter(

View File

@@ -82,13 +82,29 @@ export function getPublicBalances(reimbursements: Reimbursement[]): Balances {
return balances return balances
} }
/**
* A comparator that is stable across reimbursements.
* This ensures that a participant executing a suggested reimbursement
* does not result in completely new repayment suggestions.
*/
function compareBalancesForReimbursements(b1: any, b2: any): number {
// positive balances come before negative balances
if (b1.total > 0 && 0 > b2.total) {
return -1
} else if (b2.total > 0 && 0 > b1.total) {
return 1
}
// if signs match, sort based on userid
return b1.participantId < b2.participantId ? -1 : 1
}
export function getSuggestedReimbursements( export function getSuggestedReimbursements(
balances: Balances, balances: Balances,
): Reimbursement[] { ): Reimbursement[] {
const balancesArray = Object.entries(balances) const balancesArray = Object.entries(balances)
.map(([participantId, { total }]) => ({ participantId, total })) .map(([participantId, { total }]) => ({ participantId, total }))
.filter((b) => b.total !== 0) .filter((b) => b.total !== 0)
balancesArray.sort((b1, b2) => b2.total - b1.total) balancesArray.sort(compareBalancesForReimbursements)
const reimbursements: Reimbursement[] = [] const reimbursements: Reimbursement[] = []
while (balancesArray.length > 1) { while (balancesArray.length > 1) {
const first = balancesArray[0] const first = balancesArray[0]

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,14 @@ 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() information: z.string().optional(),
.min(2, 'Enter at least two characters.') currency: z.string().min(1, 'min1').max(5, 'max5'),
.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 +21,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 +34,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 +45,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 +67,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 +82,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 +125,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 +139,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,
}) })
@@ -48,3 +56,13 @@ export function formatFileSize(size: number) {
if (size > 1024) return `${formatNumber(size / 1024)} kB` if (size > 1024) return `${formatNumber(size / 1024)} kB`
return `${formatNumber(size)} B` return `${formatNumber(size)} B`
} }
export function normalizeString(input: string): string {
// Replaces special characters
// Input: áäåèéę
// Output: aaaeee
return input
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
}