mirror of
https://github.com/spliit-app/spliit.git
synced 2025-12-06 01:19:29 +01:00
Internationalization + Finnish language (#181)
* I18n with next-intl * package-lock * Finnish translations * Development fix * Use locale for positioning currency symbol * Translations: Expenses.ActiveUserModal * Translations: group 404 * Better translation for ExpenseCard * Apply translations in CategorySelect search * Fix for Finnish translation * Translations for ExpenseDocumentsInput * Translations for CreateFromReceipt * Fix for Finnish translation * Translations for schema errors * Fix for Finnish translation * Fixes for Finnish translations * Prettier --------- Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
This commit is contained in:
374
messages/en-US.json
Normal file
374
messages/en-US.json
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
{
|
||||||
|
"Header": {
|
||||||
|
"groups": "Groups"
|
||||||
|
},
|
||||||
|
"Footer": {
|
||||||
|
"madeIn": "Made in Montréal, Québec 🇨🇦",
|
||||||
|
"builtBy": "Built by <author>Sebastien Castiel</author> and <source>contributors</source>"
|
||||||
|
},
|
||||||
|
"Expenses": {
|
||||||
|
"title": "Expenses",
|
||||||
|
"description": "Here are the expenses that you created for your group.",
|
||||||
|
"create": "Create expense",
|
||||||
|
"createFirst": "Create the first one",
|
||||||
|
"noExpenses": "Your group doesn’t contain any expense yet.",
|
||||||
|
"exportJson": "Export to JSON",
|
||||||
|
"searchPlaceholder": "Search for an expense…",
|
||||||
|
"ActiveUserModal": {
|
||||||
|
"title": "Who are you?",
|
||||||
|
"description": "Tell us which participant you are to let us customize how the information is displayed.",
|
||||||
|
"nobody": "I don’t want to select anyone",
|
||||||
|
"save": "Save changes",
|
||||||
|
"footer": "This setting can be changed later in the group settings."
|
||||||
|
},
|
||||||
|
"Groups": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"thisWeek": "This week",
|
||||||
|
"earlierThisMonth": "Earlier this month",
|
||||||
|
"lastMonth": "Last month",
|
||||||
|
"earlierThisYear": "Earlier this year",
|
||||||
|
"lastYera": "Last year",
|
||||||
|
"older": "Older"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ExpenseCard": {
|
||||||
|
"paidBy": "Paid by <strong>{paidBy}</strong> for <paidFor></paidFor>",
|
||||||
|
"receivedBy": "Received by <strong>{paidBy}</strong> for <paidFor></paidFor>",
|
||||||
|
"yourBalance": "Your balance:"
|
||||||
|
},
|
||||||
|
"Groups": {
|
||||||
|
"myGroups": "My groups",
|
||||||
|
"create": "Create",
|
||||||
|
"loadingRecent": "Loading recent groups…",
|
||||||
|
"NoRecent": {
|
||||||
|
"description": "You have not visited any group recently.",
|
||||||
|
"create": "Create one",
|
||||||
|
"orAsk": "or ask a friend to send you the link to an existing one."
|
||||||
|
},
|
||||||
|
"recent": "Recent groups",
|
||||||
|
"starred": "Starred groups",
|
||||||
|
"archived": "Archived groups",
|
||||||
|
"archive": "Archive group",
|
||||||
|
"unarchive": "Unarchive group",
|
||||||
|
"removeRecent": "Remove from recent groups",
|
||||||
|
"RecentRemovedToast": {
|
||||||
|
"title": "Group has been removed",
|
||||||
|
"description": "The group was removed from your recent groups list.",
|
||||||
|
"undoAlt": "Undo group removal",
|
||||||
|
"undo": "Undo"
|
||||||
|
},
|
||||||
|
"AddByURL": {
|
||||||
|
"button": "Add by URL",
|
||||||
|
"title": "Add a group by URL",
|
||||||
|
"description": "If a group was shared with you, you can paste its URL here to add it to your list.",
|
||||||
|
"error": "Oops, we are not able to find the group from the URL you provided…"
|
||||||
|
},
|
||||||
|
"NotFound": {
|
||||||
|
"text": "This group does not exist.",
|
||||||
|
"link": "Go to recently visited groups"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GroupForm": {
|
||||||
|
"title": "Group information",
|
||||||
|
"NameField": {
|
||||||
|
"label": "Group name",
|
||||||
|
"placeholder": "Summer vacations",
|
||||||
|
"description": "Enter a name for your group."
|
||||||
|
},
|
||||||
|
"CurrencyField": {
|
||||||
|
"label": "Currency symbol",
|
||||||
|
"placeholder": "$, €, £…",
|
||||||
|
"description": "We’ll use it to display amounts."
|
||||||
|
},
|
||||||
|
"Participants": {
|
||||||
|
"title": "Participants",
|
||||||
|
"description": "Enter the name for each participant.",
|
||||||
|
"protectedParticipant": "This participant is part of expenses, and can not be removed.",
|
||||||
|
"new": "New",
|
||||||
|
"add": "Add participant",
|
||||||
|
"John": "John",
|
||||||
|
"Jane": "Jane",
|
||||||
|
"Jack": "Jack"
|
||||||
|
},
|
||||||
|
"Settings": {
|
||||||
|
"title": "Local settings",
|
||||||
|
"description": "These settings are set per-device, and are used to customize your experience.",
|
||||||
|
"ActiveUserField": {
|
||||||
|
"label": "Active user",
|
||||||
|
"placeholder": "Select a participant",
|
||||||
|
"none": "None",
|
||||||
|
"description": "User used as default for paying expenses."
|
||||||
|
},
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"create": "Create",
|
||||||
|
"creating": "Creating…",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ExpenseForm": {
|
||||||
|
"Income": {
|
||||||
|
"create": "Create income",
|
||||||
|
"edit": "Edit income",
|
||||||
|
"TitleField": {
|
||||||
|
"label": "Income title",
|
||||||
|
"placeholder": "Monday evening restaurant",
|
||||||
|
"description": "Enter a description for the income."
|
||||||
|
},
|
||||||
|
"DateField": {
|
||||||
|
"label": "Income date",
|
||||||
|
"description": "Enter the date the income was received."
|
||||||
|
},
|
||||||
|
"categoryFieldDescription": "Select the income category.",
|
||||||
|
"paidByField": {
|
||||||
|
"label": "Received by",
|
||||||
|
"description": "Select the participant who received the income."
|
||||||
|
},
|
||||||
|
"paidFor": {
|
||||||
|
"title": "Received for",
|
||||||
|
"description": "Select who the income was received for."
|
||||||
|
},
|
||||||
|
"splitModeDescription": "Select how to split the income.",
|
||||||
|
"attachDescription": "See and attach receipts to the income."
|
||||||
|
},
|
||||||
|
"Expense": {
|
||||||
|
"create": "Create expense",
|
||||||
|
"edit": "Edit expense",
|
||||||
|
"TitleField": {
|
||||||
|
"label": "Expense title",
|
||||||
|
"placeholder": "Monday evening restaurant",
|
||||||
|
"description": "Enter a description for the expense."
|
||||||
|
},
|
||||||
|
"DateField": {
|
||||||
|
"label": "Expense date",
|
||||||
|
"description": "Enter the date the expense was paid."
|
||||||
|
},
|
||||||
|
"categoryFieldDescription": "Select the expense category.",
|
||||||
|
"paidByField": {
|
||||||
|
"label": "Paid by",
|
||||||
|
"description": "Select the participant who paid the expense."
|
||||||
|
},
|
||||||
|
"paidFor": {
|
||||||
|
"title": "Paid for",
|
||||||
|
"description": "Select who the expense was paid for."
|
||||||
|
},
|
||||||
|
"splitModeDescription": "Select how to split the expense.",
|
||||||
|
"attachDescription": "See and attach receipts to the expense."
|
||||||
|
},
|
||||||
|
"amountField": {
|
||||||
|
"label": "Amount"
|
||||||
|
},
|
||||||
|
"isReimbursementField": {
|
||||||
|
"label": "This is a reimbursement"
|
||||||
|
},
|
||||||
|
"categoryField": {
|
||||||
|
"label": "Category"
|
||||||
|
},
|
||||||
|
"notesField": {
|
||||||
|
"label": "Notes"
|
||||||
|
},
|
||||||
|
"selectNone": "Select none",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"shares": "share(s)",
|
||||||
|
"advancedOptions": "Advanced splitting options…",
|
||||||
|
"SplitModeField": {
|
||||||
|
"label": "Split mode",
|
||||||
|
"evenly": "Evenly",
|
||||||
|
"byShares": "Unevenly – By shares",
|
||||||
|
"byPercentage": "Unevenly – By percentage",
|
||||||
|
"byAmount": "Unevenly – By amount",
|
||||||
|
"saveAsDefault": "Save as default splitting options"
|
||||||
|
},
|
||||||
|
"DeletePopup": {
|
||||||
|
"label": "Delete",
|
||||||
|
"title": "Delete this expense?",
|
||||||
|
"description": "Do you really want to delete this expense? This action is irreversible.",
|
||||||
|
"yes": "Yes",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"attachDocuments": "Attach documents",
|
||||||
|
"create": "Create",
|
||||||
|
"creating": "Creating…",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"ExpenseDocumentsInput": {
|
||||||
|
"TooBigToast": {
|
||||||
|
"title": "The file is too big",
|
||||||
|
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
|
||||||
|
},
|
||||||
|
"ErrorToast": {
|
||||||
|
"title": "Error while uploading document",
|
||||||
|
"description": "Something wrong happened when uploading the document. Please retry later or select a different file.",
|
||||||
|
"retry": "Retry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CreateFromReceipt": {
|
||||||
|
"Dialog": {
|
||||||
|
"triggerTitle": "Create expense from receipt",
|
||||||
|
"title": "Create from receipt",
|
||||||
|
"description": "Extract the expense information from a receipt photo.",
|
||||||
|
"body": "Upload the photo of a receipt, and we’ll scan it to extract the expense information if we can.",
|
||||||
|
"selectImage": "Select image…",
|
||||||
|
"titleLabel": "Title:",
|
||||||
|
"categoryLabel": "Category:",
|
||||||
|
"amountLabel": "Amount:",
|
||||||
|
"dateLabel": "Date:",
|
||||||
|
"editNext": "You’ll be able to edit the expense information next.",
|
||||||
|
"continue": "Continue"
|
||||||
|
},
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"TooBigToast": {
|
||||||
|
"title": "The file is too big",
|
||||||
|
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
|
||||||
|
},
|
||||||
|
"ErrorToast": {
|
||||||
|
"title": "Error while uploading document",
|
||||||
|
"description": "Something wrong happened when uploading the document. Please retry later or select a different file.",
|
||||||
|
"retry": "Retry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Balances": {
|
||||||
|
"title": "Balances",
|
||||||
|
"description": "This is the amount that each participant paid or was paid for.",
|
||||||
|
"Reimbursements": {
|
||||||
|
"title": "Suggested reimbursements",
|
||||||
|
"description": "Here are suggestions for optimized reimbursements between participants.",
|
||||||
|
"noImbursements": "It looks like your group doesn’t need any reimbursement 😁",
|
||||||
|
"owes": "<strong>{from}</strong> owes <strong>{to}</strong>",
|
||||||
|
"markAsPaid": "Mark as paid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Stats": {
|
||||||
|
"title": "Stats",
|
||||||
|
"Totals": {
|
||||||
|
"title": "Totals",
|
||||||
|
"description": "Spending summary of the entire group.",
|
||||||
|
"groupSpendings": "Total group spendings",
|
||||||
|
"groupEarnings": "Total group earnings",
|
||||||
|
"yourSpendings": "Your total spendings",
|
||||||
|
"yourEarnings": "Your total earnings",
|
||||||
|
"yourShare": "Your total share"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Activity": {
|
||||||
|
"title": "Activity",
|
||||||
|
"description": "Overview of all activity in this group.",
|
||||||
|
"noActivity": "There is not yet any activity in your group.",
|
||||||
|
"someone": "Someone",
|
||||||
|
"settingsModified": "Group settings were modified by <strong>{participant}</strong>.",
|
||||||
|
"expenseCreated": "Expense <em>{expense}</em> created by <strong>{participant}</strong>.",
|
||||||
|
"expenseUpdated": "Expense <em>{expense}</em> updated by <strong>{participant}</strong>.",
|
||||||
|
"expenseDeleted": "Expense <em>{expense}</em> deleted by <strong>{participant}</strong>.",
|
||||||
|
"Groups": {
|
||||||
|
"today": "Today",
|
||||||
|
"yesterday": "Yesterday",
|
||||||
|
"earlierThisWeek": "Earlier this week",
|
||||||
|
"lastWeek": "Last week",
|
||||||
|
"earlierThisMonth": "Earlier this month",
|
||||||
|
"lastMonth": "Last month",
|
||||||
|
"earlierThisYear": "Earlier this year",
|
||||||
|
"lastYear": "Last year",
|
||||||
|
"older": "Older"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Settings": {
|
||||||
|
"title": "Settings"
|
||||||
|
},
|
||||||
|
"Locale": {
|
||||||
|
"en-US": "English (US)",
|
||||||
|
"fi": "Suomi"
|
||||||
|
},
|
||||||
|
"Share": {
|
||||||
|
"title": "Share",
|
||||||
|
"description": "For other participants to see the group and add expenses, share its URL with them.",
|
||||||
|
"warning": "Warning!",
|
||||||
|
"warningHelp": "Every person with the group URL will be able to see and edit expenses. Share with caution!"
|
||||||
|
},
|
||||||
|
"SchemaErrors": {
|
||||||
|
"min1": "Enter at least one character.",
|
||||||
|
"min2": "Enter at least two characters.",
|
||||||
|
"max5": "Enter at most five characters.",
|
||||||
|
"max50": "Enter at most 50 characters.",
|
||||||
|
"duplicateParticipantName": "Another participant already has this name.",
|
||||||
|
"titleRequired": "Please enter a title.",
|
||||||
|
"invalidNumber": "Invalid number.",
|
||||||
|
"amountRequired": "You must enter an amount.",
|
||||||
|
"amountNotZero": "The amount must not be zero.",
|
||||||
|
"amountTenMillion": "The amount must be lower than 10,000,000.",
|
||||||
|
"paidByRequired": "You must select a participant.",
|
||||||
|
"paidForMin1": "The expense must be paid for at least one participant.",
|
||||||
|
"noZeroShares": "All shares must be higher than 0.",
|
||||||
|
"amountSum": "Sum of amounts must equal the expense amount.",
|
||||||
|
"percentageSum": "Sum of percentages must equal 100."
|
||||||
|
},
|
||||||
|
"Categories": {
|
||||||
|
"search": "Search category...",
|
||||||
|
"noCategory": "No category found.",
|
||||||
|
"Uncategorized": {
|
||||||
|
"heading": "Uncategorized",
|
||||||
|
"General": "General",
|
||||||
|
"Payment": "Payment"
|
||||||
|
},
|
||||||
|
"Entertainment": {
|
||||||
|
"heading": "Entertainment",
|
||||||
|
"Entertainment": "Entertainment",
|
||||||
|
"Games": "Games",
|
||||||
|
"Movies": "Movies",
|
||||||
|
"Music": "Music",
|
||||||
|
"Sports": "Sports"
|
||||||
|
},
|
||||||
|
"Food and Drink": {
|
||||||
|
"heading": "Food and Drink",
|
||||||
|
"Food and Drink": "Food and Drink",
|
||||||
|
"Dining Out": "Dining Out",
|
||||||
|
"Groceries": "Groceries",
|
||||||
|
"Liquor": "Liquor"
|
||||||
|
},
|
||||||
|
"Home": {
|
||||||
|
"heading": "Home",
|
||||||
|
"Home": "Home",
|
||||||
|
"Electronics": "Electronics",
|
||||||
|
"Furniture": "Furniture",
|
||||||
|
"Household Supplies": "Household Supplies",
|
||||||
|
"Maintenance": "Maintenance",
|
||||||
|
"Mortgage": "Mortgage",
|
||||||
|
"Pets": "Pets",
|
||||||
|
"Rent": "Rent",
|
||||||
|
"Services": "Services"
|
||||||
|
},
|
||||||
|
"Life": {
|
||||||
|
"heading": "Life",
|
||||||
|
"Childcare": "Childcare",
|
||||||
|
"Clothing": "Clothing",
|
||||||
|
"Education": "Education",
|
||||||
|
"Gifts": "Gifts",
|
||||||
|
"Insurance": "Insurance",
|
||||||
|
"Medical Expenses": "Medical Expenses",
|
||||||
|
"Taxes": "Taxes"
|
||||||
|
},
|
||||||
|
"Transportation": {
|
||||||
|
"heading": "Transportation",
|
||||||
|
"Transportation": "Transportation",
|
||||||
|
"Bicycle": "Bicycle",
|
||||||
|
"Bus/Train": "Bus/Train",
|
||||||
|
"Car": "Car",
|
||||||
|
"Gas/Fuel": "Gas/Fuel",
|
||||||
|
"Hotel": "Hotel",
|
||||||
|
"Parking": "Parking",
|
||||||
|
"Plane": "Plane",
|
||||||
|
"Taxi": "Taxi"
|
||||||
|
},
|
||||||
|
"Utilities": {
|
||||||
|
"heading": "Utilities",
|
||||||
|
"Utilities": "Utilities",
|
||||||
|
"Cleaning": "Cleaning",
|
||||||
|
"Electricity": "Electricity",
|
||||||
|
"Heat/Gas": "Heat/Gas",
|
||||||
|
"Trash": "Trash",
|
||||||
|
"TV/Phone/Internet": "TV/Phone/Internet",
|
||||||
|
"Water": "Water"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
374
messages/fi.json
Normal file
374
messages/fi.json
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
{
|
||||||
|
"Header": {
|
||||||
|
"groups": "Ryhmät"
|
||||||
|
},
|
||||||
|
"Footer": {
|
||||||
|
"madeIn": "Made in Montréal, Québec 🇨🇦",
|
||||||
|
"builtBy": "Tekijät: <author>Sebastien Castiel</author> ja <source>muut osallistujat</source>"
|
||||||
|
},
|
||||||
|
"Expenses": {
|
||||||
|
"title": "Kulut",
|
||||||
|
"description": "Tässä ovat ryhmässä luodut kulut.",
|
||||||
|
"create": "Lisää kulu",
|
||||||
|
"createFirst": "Lisää ensimmäinen kulu",
|
||||||
|
"noExpenses": "Ryhmälläsi ei ole vielä yhtään kulua.",
|
||||||
|
"exportJson": "Vie JSON-tiedostoon",
|
||||||
|
"searchPlaceholder": "Etsi kulua…",
|
||||||
|
"ActiveUserModal": {
|
||||||
|
"title": "Kuka olet?",
|
||||||
|
"description": "Valitse kuka osallistujista olet, jotta tiedot näkyvät oikein.",
|
||||||
|
"nobody": "En halua valita ketään",
|
||||||
|
"save": "Tallenna muutokset",
|
||||||
|
"footer": "Tämän asetuksen voi vaihtaa myöhemmin ryhmän asetuksista."
|
||||||
|
},
|
||||||
|
"Groups": {
|
||||||
|
"upcoming": "Tulevat",
|
||||||
|
"thisWeek": "Tällä viikolla",
|
||||||
|
"earlierThisMonth": "Aikaisemmin tässä kuussa",
|
||||||
|
"lastMonth": "Viime kuussa",
|
||||||
|
"earlierThisYear": "Aikaisemmin tänä vuonna",
|
||||||
|
"lastYear": "Viime vuonna",
|
||||||
|
"older": "Vanhemmat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ExpenseCard": {
|
||||||
|
"paidBy": "<strong>{paidBy}</strong> maksoi {forCount, plural, =1 {henkilön} other {henkilöiden}} <paidFor></paidFor> puolesta",
|
||||||
|
"receivedBy": "<strong>{paidBy}</strong> sai rahaa {forCount, plural, =1 {henkilön} other {henkilöiden}} <paidFor></paidFor> puolesta",
|
||||||
|
"yourBalance": "Saldosi:"
|
||||||
|
},
|
||||||
|
"Groups": {
|
||||||
|
"myGroups": "Omat ryhmät",
|
||||||
|
"create": "Luo ryhmä",
|
||||||
|
"loadingRecent": "Ladataan äskettäisiä ryhmiä…",
|
||||||
|
"NoRecent": {
|
||||||
|
"description": "Et ole ollut missään ryhmässä äskettäin.",
|
||||||
|
"create": "Luo uusi ryhmä",
|
||||||
|
"orAsk": "tai pyydä ystävää lähettämään linkki olemassaolevaan ryhmään."
|
||||||
|
},
|
||||||
|
"recent": "Äskettäiset",
|
||||||
|
"starred": "Suosikit",
|
||||||
|
"archived": "Arkistoidut",
|
||||||
|
"archive": "Arkistoi ryhmä",
|
||||||
|
"unarchive": "Palauta ryhmä arkistosta",
|
||||||
|
"removeRecent": "Poista äskettäisistä",
|
||||||
|
"RecentRemovedToast": {
|
||||||
|
"title": "Ryhmä poistettu",
|
||||||
|
"description": "Ryhmä poistettu äskettäisten listaltasi.",
|
||||||
|
"undoAlt": "Peruuta ryhmän poisto",
|
||||||
|
"undo": "Peruuta"
|
||||||
|
},
|
||||||
|
"AddByURL": {
|
||||||
|
"button": "Lisää URLilla",
|
||||||
|
"title": "Lisää ryhmä URL-osoitteella",
|
||||||
|
"description": "Jos ryhmä on jaettu sinulle, voit lisätä sen listaasi liittämällä URL-osoitteen tähän.",
|
||||||
|
"error": "Hups, emme löytäneet ryhmää antamastasi URL-osoitteesta…"
|
||||||
|
},
|
||||||
|
"NotFound": {
|
||||||
|
"text": "Tätä ryhmää ei löydy.",
|
||||||
|
"link": "Siirry äskettäisiin ryhmiin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GroupForm": {
|
||||||
|
"title": "Ryhmän tiedot",
|
||||||
|
"NameField": {
|
||||||
|
"label": "Ryhmän nimi",
|
||||||
|
"placeholder": "Kesälomareissu",
|
||||||
|
"description": "Syötä ryhmäsi nimi."
|
||||||
|
},
|
||||||
|
"CurrencyField": {
|
||||||
|
"label": "Valuuttamerkki",
|
||||||
|
"placeholder": "$, €, £…",
|
||||||
|
"description": "Näytetään rahasummien yhteydessä."
|
||||||
|
},
|
||||||
|
"Participants": {
|
||||||
|
"title": "Osallistujat",
|
||||||
|
"description": "Syötä jokaisen osallistujan nimi.",
|
||||||
|
"protectedParticipant": "Tätä osallistujaa ei voida poistaa, koska hän osallistuu kuluihin.",
|
||||||
|
"add": "Lisää osallistuja",
|
||||||
|
"new": "Uusi",
|
||||||
|
"John": "Antti",
|
||||||
|
"Jane": "Laura",
|
||||||
|
"Jack": "Jussi"
|
||||||
|
},
|
||||||
|
"Settings": {
|
||||||
|
"title": "Paikalliset asetukset",
|
||||||
|
"description": "Nämä asetukset ovat laitekohtaisia. Voit muokata niillä käytettävyyttä.",
|
||||||
|
"ActiveUserField": {
|
||||||
|
"label": "Aktiivinen käyttäjä",
|
||||||
|
"placeholder": "Valitse osallistuja",
|
||||||
|
"none": "Ei kukaan",
|
||||||
|
"description": "Käytetään kulujen oletusmaksajana."
|
||||||
|
},
|
||||||
|
"save": "Tallenna",
|
||||||
|
"saving": "Tallennetaan…",
|
||||||
|
"create": "Luo ryhmä",
|
||||||
|
"creating": "Luodaan…",
|
||||||
|
"cancel": "Peruuta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ExpenseForm": {
|
||||||
|
"Income": {
|
||||||
|
"create": "Lisää tulo",
|
||||||
|
"edit": "Muokkaa tuloa",
|
||||||
|
"TitleField": {
|
||||||
|
"label": "Otsikko",
|
||||||
|
"placeholder": "Maanantain ravintola",
|
||||||
|
"description": "Anna lyhyt kuvaus tulolle."
|
||||||
|
},
|
||||||
|
"DateField": {
|
||||||
|
"label": "Päivä",
|
||||||
|
"description": "Valitse päivä jolloin tulo saatiin."
|
||||||
|
},
|
||||||
|
"categoryFieldDescription": "Valitse tulokategoria.",
|
||||||
|
"paidByField": {
|
||||||
|
"label": "Vastaanottaja",
|
||||||
|
"description": "Valitse kuka vastaanotti tulon."
|
||||||
|
},
|
||||||
|
"paidFor": {
|
||||||
|
"title": "Tulon jakaminen",
|
||||||
|
"description": "Valitse kenelle tulo jaetaan."
|
||||||
|
},
|
||||||
|
"splitModeDescription": "Valitse miten tulo jaetaan osallistujien kesken.",
|
||||||
|
"attachDescription": "Katso ja liitä tuloon liittyviä kuitteja."
|
||||||
|
},
|
||||||
|
"Expense": {
|
||||||
|
"create": "Lisää kulu",
|
||||||
|
"edit": "Muokkaa kulua",
|
||||||
|
"TitleField": {
|
||||||
|
"label": "Otsikko",
|
||||||
|
"placeholder": "Maanantain ravintola",
|
||||||
|
"description": "Anna lyhyt kuvaus kululle."
|
||||||
|
},
|
||||||
|
"DateField": {
|
||||||
|
"label": "Päivä",
|
||||||
|
"description": "Valitse päivä jolloin kulu maksettiin."
|
||||||
|
},
|
||||||
|
"categoryFieldDescription": "Valitse kulukategoria.",
|
||||||
|
"paidByField": {
|
||||||
|
"label": "Maksaja",
|
||||||
|
"description": "Valitse kuka maksoi kulun."
|
||||||
|
},
|
||||||
|
"paidFor": {
|
||||||
|
"title": "Kulun jakaminen",
|
||||||
|
"description": "Valitse ketkä osallistuvat kuluun."
|
||||||
|
},
|
||||||
|
"splitModeDescription": "Valitse miten kulu jaetaan osallistujien kesken.",
|
||||||
|
"attachDescription": "Katso ja liitä kuluun liittyviä kuitteja."
|
||||||
|
},
|
||||||
|
"amountField": {
|
||||||
|
"label": "Summa"
|
||||||
|
},
|
||||||
|
"isReimbursementField": {
|
||||||
|
"label": "Tämä on velanmaksu"
|
||||||
|
},
|
||||||
|
"categoryField": {
|
||||||
|
"label": "Kategoria"
|
||||||
|
},
|
||||||
|
"notesField": {
|
||||||
|
"label": "Muistiinpanot"
|
||||||
|
},
|
||||||
|
"selectNone": "Tyhjennä valinnat",
|
||||||
|
"selectAll": "Valitse kaikki",
|
||||||
|
"shares": "osuutta",
|
||||||
|
"advancedOptions": "Lisäasetuksia jakamiseen…",
|
||||||
|
"SplitModeField": {
|
||||||
|
"label": "Jakamistapa",
|
||||||
|
"evenly": "Tasan",
|
||||||
|
"byShares": "Epätasan – osuuksien mukaan",
|
||||||
|
"byPercentage": "Epätasan – prosenttien mukaan",
|
||||||
|
"byAmount": "Epätasan – summan mukaan",
|
||||||
|
"saveAsDefault": "Tallenna oletustavaksi"
|
||||||
|
},
|
||||||
|
"DeletePopup": {
|
||||||
|
"label": "Poista",
|
||||||
|
"title": "Poistetaanko tämä kulu?",
|
||||||
|
"description": "Haluatko varmasti poistaa tämän kulun? Poistoa ei voi peruuttaa.",
|
||||||
|
"yes": "Kyllä",
|
||||||
|
"cancel": "Peruuta"
|
||||||
|
},
|
||||||
|
"attachDocuments": "Liitä dokumenttejä",
|
||||||
|
"create": "Lisää kulu",
|
||||||
|
"creating": "Luodaan kulua…",
|
||||||
|
"save": "Tallenna",
|
||||||
|
"saving": "Tallennetaan…",
|
||||||
|
"cancel": "Peruuta"
|
||||||
|
},
|
||||||
|
"ExpenseDocumentsInput": {
|
||||||
|
"TooBigToast": {
|
||||||
|
"title": "Tiedosto on liian suuri",
|
||||||
|
"description": "Maksimikoko ladattavalle tiedostolle on {maxSize}. Tiedostosi on ${size}."
|
||||||
|
},
|
||||||
|
"ErrorToast": {
|
||||||
|
"title": "Virhe tiedostoa ladattaessa",
|
||||||
|
"description": "Jokin meni vikaan dokumentin lataamisessa. Yritä myöhemmin uudelleen tai valitse toinen tiedosto.",
|
||||||
|
"retry": "Yritä uudelleen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CreateFromReceipt": {
|
||||||
|
"Dialog": {
|
||||||
|
"triggerTitle": "Luo kulu kuitista",
|
||||||
|
"title": "Luo kuitista",
|
||||||
|
"description": "Lue kuitin valokuvasta kulun tiedot.",
|
||||||
|
"body": "Lataa kuitista valokuva. Siitä skannataan tiedot kulua varten.",
|
||||||
|
"selectImage": "Valitse kuva…",
|
||||||
|
"titleLabel": "Otsikko:",
|
||||||
|
"categoryLabel": "Kategoria:",
|
||||||
|
"amountLabel": "Summa:",
|
||||||
|
"dateLabel": "Päivä:",
|
||||||
|
"editNext": "Voit muokata kulun tietoja seuraavaksi.",
|
||||||
|
"continue": "Jatka"
|
||||||
|
},
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"TooBigToast": {
|
||||||
|
"title": "The file is too big",
|
||||||
|
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
|
||||||
|
},
|
||||||
|
"ErrorToast": {
|
||||||
|
"title": "Error while uploading document",
|
||||||
|
"description": "Something wrong happened when uploading the document. Please retry later or select a different file.",
|
||||||
|
"retry": "Retry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Balances": {
|
||||||
|
"title": "Saldo",
|
||||||
|
"description": "Osallistujien saatavat tai velat.",
|
||||||
|
"Reimbursements": {
|
||||||
|
"title": "Maksuehdotus",
|
||||||
|
"description": "Optimoitu ehdotus kuka maksaa kenellekin.",
|
||||||
|
"noImbursements": "Näyttää siltä, että kaikki ovat sujut 😁",
|
||||||
|
"owes": "<strong>{from}</strong> maksaa henkilölle <strong>{to}</strong>",
|
||||||
|
"markAsPaid": "Merkitse maksetuksi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Stats": {
|
||||||
|
"title": "Tilastot",
|
||||||
|
"Totals": {
|
||||||
|
"title": "Yhteenveto",
|
||||||
|
"description": "Koko ryhmän kulut.",
|
||||||
|
"groupSpendings": "Koko ryhmän kulutus",
|
||||||
|
"groupEarnings": "Koko ryhmän saatavat",
|
||||||
|
"yourSpendings": "Kulutuksesi",
|
||||||
|
"yourEarnings": "Saatavasi",
|
||||||
|
"yourShare": "Osuutesi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Activity": {
|
||||||
|
"title": "Tapahtumat",
|
||||||
|
"description": "Yleisnäkymä ryhmän kaikista tapahtumista.",
|
||||||
|
"noActivity": "Ryhmässäsi ei ole vielä tapahtumia.",
|
||||||
|
"someone": "Tuntematon",
|
||||||
|
"settingsModified": "<strong>{participant}</strong> muokkasi ryhmän asetuksia.",
|
||||||
|
"expenseCreated": "<strong>{participant}</strong> lisäsi kulun <em>{expense}</em>.",
|
||||||
|
"expenseUpdated": "<strong>{participant}</strong> muokkasi kulua <em>{expense}</em>.",
|
||||||
|
"expenseDeleted": "<strong>{participant}</strong> poisti kulun <em>{expense}</em>.",
|
||||||
|
"Groups": {
|
||||||
|
"today": "Tänään",
|
||||||
|
"yesterday": "Eilen",
|
||||||
|
"earlierThisWeek": "Tällä viikolla",
|
||||||
|
"lastWeek": "Viime viikolla",
|
||||||
|
"earlierThisMonth": "Tässä kuussa",
|
||||||
|
"lastMonth": "Viime kuussa",
|
||||||
|
"earlierThisYear": "Tänä vuonna",
|
||||||
|
"lastYear": "Viime vuonna",
|
||||||
|
"older": "Vanhemmat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Settings": {
|
||||||
|
"title": "Asetukset"
|
||||||
|
},
|
||||||
|
"Locale": {
|
||||||
|
"en-US": "English (US)",
|
||||||
|
"fi": "Suomi"
|
||||||
|
},
|
||||||
|
"Share": {
|
||||||
|
"title": "Jaa",
|
||||||
|
"description": "Jaa ryhmän URL muille jäsenille, jotta he voivat nähdä sen ja lisätä kuluja.",
|
||||||
|
"warning": "Varoitus!",
|
||||||
|
"warningHelp": "Tällä URLilla kuka tahansa pääsee näkemään ja muokkaamaan kuluja. Jaa harkiten!"
|
||||||
|
},
|
||||||
|
"SchemaErrors": {
|
||||||
|
"min1": "Syötä vähintään yksi merkki.",
|
||||||
|
"min2": "Syötä vähintään kaksi merkkiä.",
|
||||||
|
"max5": "Syötä enintään viisi merkkiä.",
|
||||||
|
"max50": "Syötä enintään 50 merkkiä.",
|
||||||
|
"duplicateParticipantName": "Tämä nimi on jo toisella osallistujalla.",
|
||||||
|
"titleRequired": "Otsikko puuttuu.",
|
||||||
|
"invalidNumber": "Epäkelpo numero.",
|
||||||
|
"amountRequired": "Summa puuttuu.",
|
||||||
|
"amountNotZero": "Summa ei voi olla nolla.",
|
||||||
|
"amountTenMillion": "Summan pitää olla pienempi kuin 10 000 000.",
|
||||||
|
"paidByRequired": "Osallistuja puuttuu.",
|
||||||
|
"paidForMin1": "Valitse vähintään yksi osallistuja.",
|
||||||
|
"noZeroShares": "Jokaisen osuuden täytyy olla suurempi kuin 0.",
|
||||||
|
"amountSum": "Osuuksien summan täytyy vastata kulun summaa.",
|
||||||
|
"percentageSum": "Prosenttiosuuksien summan täytyy olla 100."
|
||||||
|
},
|
||||||
|
"Categories": {
|
||||||
|
"search": "Etsi kategoriaa...",
|
||||||
|
"noCategory": "Kategoriaa ei löydy.",
|
||||||
|
"Uncategorized": {
|
||||||
|
"heading": "Yleiset",
|
||||||
|
"General": "Yleinen",
|
||||||
|
"Payment": "Maksu"
|
||||||
|
},
|
||||||
|
"Entertainment": {
|
||||||
|
"heading": "Viihde",
|
||||||
|
"Entertainment": "Viihde",
|
||||||
|
"Games": "Pelit",
|
||||||
|
"Movies": "Elokuvat",
|
||||||
|
"Music": "Musiikki",
|
||||||
|
"Sports": "Urheilu"
|
||||||
|
},
|
||||||
|
"Food and Drink": {
|
||||||
|
"heading": "Ruoka ja juoma",
|
||||||
|
"Food and Drink": "Ruoka ja juoma",
|
||||||
|
"Dining Out": "Ulkona syöminen",
|
||||||
|
"Groceries": "Marketti",
|
||||||
|
"Liquor": "Alkoholi"
|
||||||
|
},
|
||||||
|
"Home": {
|
||||||
|
"heading": "Koti",
|
||||||
|
"Home": "Koti",
|
||||||
|
"Electronics": "Elektroniikka",
|
||||||
|
"Furniture": "Huonekalut",
|
||||||
|
"Household Supplies": "Taloustavarat",
|
||||||
|
"Maintenance": "Huolto",
|
||||||
|
"Mortgage": "Laina",
|
||||||
|
"Pets": "Lemmikit",
|
||||||
|
"Rent": "Vuokra",
|
||||||
|
"Services": "Palvelut"
|
||||||
|
},
|
||||||
|
"Life": {
|
||||||
|
"heading": "Elämä",
|
||||||
|
"Childcare": "Lastenhoito",
|
||||||
|
"Clothing": "Vaatteet",
|
||||||
|
"Education": "Opiskelu",
|
||||||
|
"Gifts": "Lahjat",
|
||||||
|
"Insurance": "Vakuutukset",
|
||||||
|
"Medical Expenses": "Terveydenhoito",
|
||||||
|
"Taxes": "Verot"
|
||||||
|
},
|
||||||
|
"Transportation": {
|
||||||
|
"heading": "Liikenne",
|
||||||
|
"Transportation": "Liikenne",
|
||||||
|
"Bicycle": "Polkupyörä",
|
||||||
|
"Bus/Train": "Bussi/juna",
|
||||||
|
"Car": "Auto",
|
||||||
|
"Gas/Fuel": "Polttoaine",
|
||||||
|
"Hotel": "Hotelli",
|
||||||
|
"Parking": "Pysäköinti",
|
||||||
|
"Plane": "Lentäminen",
|
||||||
|
"Taxi": "Taksi"
|
||||||
|
},
|
||||||
|
"Utilities": {
|
||||||
|
"heading": "Sekalaiset",
|
||||||
|
"Utilities": "Sekalaiset",
|
||||||
|
"Cleaning": "Siivous",
|
||||||
|
"Electricity": "Sähkö",
|
||||||
|
"Heat/Gas": "Lämmitys",
|
||||||
|
"Trash": "Jätehuolto",
|
||||||
|
"TV/Phone/Internet": "TV/Puhelin/Internet",
|
||||||
|
"Water": "Vesi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
/**
|
import createNextIntlPlugin from 'next-intl/plugin'
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin()
|
||||||
|
|
||||||
|
/**
|
||||||
* Undefined entries are not supported. Push optional patterns to this array only if defined.
|
* Undefined entries are not supported. Push optional patterns to this array only if defined.
|
||||||
* @type {import('next/dist/shared/lib/image-config').RemotePattern}
|
* @type {import('next/dist/shared/lib/image-config').RemotePattern}
|
||||||
*/
|
*/
|
||||||
@@ -31,4 +35,4 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
export default withNextIntl(nextConfig)
|
||||||
113
package-lock.json
generated
113
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formatjs/intl-localematcher": "^0.5.4",
|
||||||
"@hookform/resolvers": "^3.3.2",
|
"@hookform/resolvers": "^3.3.2",
|
||||||
"@prisma/client": "^5.6.0",
|
"@prisma/client": "^5.6.0",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
@@ -33,7 +34,9 @@
|
|||||||
"embla-carousel-react": "^8.0.0-rc21",
|
"embla-carousel-react": "^8.0.0-rc21",
|
||||||
"lucide-react": "^0.290.0",
|
"lucide-react": "^0.290.0",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
|
"negotiator": "^0.6.3",
|
||||||
"next": "^14.2.3",
|
"next": "^14.2.3",
|
||||||
|
"next-intl": "^3.17.2",
|
||||||
"next-s3-upload": "^0.3.4",
|
"next-s3-upload": "^0.3.4",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"next13-progressbar": "^1.1.1",
|
"next13-progressbar": "^1.1.1",
|
||||||
@@ -55,6 +58,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@total-typescript/ts-reset": "^0.5.1",
|
"@total-typescript/ts-reset": "^0.5.1",
|
||||||
"@types/content-disposition": "^0.5.8",
|
"@types/content-disposition": "^0.5.8",
|
||||||
|
"@types/negotiator": "^0.6.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.10.9",
|
"@types/pg": "^8.10.9",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
@@ -1006,6 +1010,50 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
|
||||||
"integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A=="
|
"integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@formatjs/ecma402-abstract": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/intl-localematcher": "0.5.4",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@formatjs/fast-memoize": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@formatjs/icu-messageformat-parser": {
|
||||||
|
"version": "2.7.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.8.tgz",
|
||||||
|
"integrity": "sha512-nBZJYmhpcSX0WeJ5SDYUkZ42AgR3xiyhNCsQweFx3cz/ULJjym8bHAzWKvG5e2+1XO98dBYC0fWeeAECAVSwLA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/ecma402-abstract": "2.0.0",
|
||||||
|
"@formatjs/icu-skeleton-parser": "1.8.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@formatjs/icu-skeleton-parser": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-k4ERKgw7aKGWJZgTarIcNEmvyTVD9FYh0mTrrBMHZ1b8hUu6iOJ4SzsZlo3UNAvHYa+PnvntIwRPt1/vy4nA9Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/ecma402-abstract": "2.0.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@formatjs/intl-localematcher": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz",
|
||||||
@@ -3116,6 +3164,12 @@
|
|||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-JkXTOdKs5MF086b/pt8C3+yVp3iDUwG635L7oCH6HvJvvr6lSUU5oe/gLXnPEfYRROHjJIPgCV6cuAg8gGkntQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.8.9",
|
"version": "20.8.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz",
|
||||||
@@ -5837,6 +5891,17 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/intl-messageformat": {
|
||||||
|
"version": "10.5.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.5.14.tgz",
|
||||||
|
"integrity": "sha512-IjC6sI0X7YRjjyVH9aUgdftcmZK7WXdHeil4KwbjDnRWjnVitKpAx3rr6t6di1joFp5188VqKcobOPA6mCLG/w==",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/ecma402-abstract": "2.0.0",
|
||||||
|
"@formatjs/fast-memoize": "2.2.0",
|
||||||
|
"@formatjs/icu-messageformat-parser": "2.7.8",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/invariant": {
|
"node_modules/invariant": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
@@ -6577,6 +6642,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "14.2.3",
|
"version": "14.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz",
|
||||||
@@ -6626,6 +6699,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-intl": {
|
||||||
|
"version": "3.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.17.2.tgz",
|
||||||
|
"integrity": "sha512-X2ly23e1lC5vdWHaJFBDZi/0iornEdFQQtqJmmPOb7WD+LDssm9vAnx+hJshYGjddaP3rUmyWaPgePCQqaxm1g==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/amannn"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/intl-localematcher": "^0.2.32",
|
||||||
|
"negotiator": "^0.6.3",
|
||||||
|
"use-intl": "^3.17.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-intl/node_modules/@formatjs/intl-localematcher": {
|
||||||
|
"version": "0.2.32",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz",
|
||||||
|
"integrity": "sha512-k/MEBstff4sttohyEpXxCmC3MqbUn9VvHGlZ8fauLzkbwXmVrEeyzS+4uhrvAk9DWU9/7otYWxyDox4nT/KVLQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next-s3-upload": {
|
"node_modules/next-s3-upload": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/next-s3-upload/-/next-s3-upload-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/next-s3-upload/-/next-s3-upload-0.3.4.tgz",
|
||||||
@@ -8674,6 +8775,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-intl": {
|
||||||
|
"version": "3.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.17.2.tgz",
|
||||||
|
"integrity": "sha512-9lPgt41nS8x4AYCLfIC9VKCmamnVxzPM2nze7lpp/I1uaSSQvIz5MQpYUFikv08cMUsCwAWahU0e+arHInpdcw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/fast-memoize": "^2.2.0",
|
||||||
|
"intl-messageformat": "^10.5.14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sidecar": {
|
"node_modules/use-sidecar": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"start-container": "docker compose --env-file container.env up"
|
"start-container": "docker compose --env-file container.env up"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formatjs/intl-localematcher": "^0.5.4",
|
||||||
"@hookform/resolvers": "^3.3.2",
|
"@hookform/resolvers": "^3.3.2",
|
||||||
"@prisma/client": "^5.6.0",
|
"@prisma/client": "^5.6.0",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
@@ -39,7 +40,9 @@
|
|||||||
"embla-carousel-react": "^8.0.0-rc21",
|
"embla-carousel-react": "^8.0.0-rc21",
|
||||||
"lucide-react": "^0.290.0",
|
"lucide-react": "^0.290.0",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
|
"negotiator": "^0.6.3",
|
||||||
"next": "^14.2.3",
|
"next": "^14.2.3",
|
||||||
|
"next-intl": "^3.17.2",
|
||||||
"next-s3-upload": "^0.3.4",
|
"next-s3-upload": "^0.3.4",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"next13-progressbar": "^1.1.1",
|
"next13-progressbar": "^1.1.1",
|
||||||
@@ -61,6 +64,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@total-typescript/ts-reset": "^0.5.1",
|
"@total-typescript/ts-reset": "^0.5.1",
|
||||||
"@types/content-disposition": "^0.5.8",
|
"@types/content-disposition": "^0.5.8",
|
||||||
|
"@types/negotiator": "^0.6.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.10.9",
|
"@types/pg": "^8.10.9",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
|
|||||||
@@ -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>“{chunks}”</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>“{expense}”</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>“{expense}”</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>“{expense}”</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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 don’t 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 we’ll 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>You’ll 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { normalizeString } from '@/lib/utils'
|
import { normalizeString } from '@/lib/utils'
|
||||||
import { Participant } from '@prisma/client'
|
import { Participant } from '@prisma/client'
|
||||||
import dayjs, { type Dayjs } from 'dayjs'
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useInView } from 'react-intersection-observer'
|
import { useInView } from 'react-intersection-observer'
|
||||||
@@ -24,13 +25,13 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EXPENSE_GROUPS = {
|
const EXPENSE_GROUPS = {
|
||||||
UPCOMING: 'Upcoming',
|
UPCOMING: 'upcoming',
|
||||||
THIS_WEEK: 'This week',
|
THIS_WEEK: 'thisWeek',
|
||||||
EARLIER_THIS_MONTH: 'Earlier this month',
|
EARLIER_THIS_MONTH: 'earlierThisMonth',
|
||||||
LAST_MONTH: 'Last month',
|
LAST_MONTH: 'lastMonth',
|
||||||
EARLIER_THIS_YEAR: 'Earlier this year',
|
EARLIER_THIS_YEAR: 'earlierThisYear',
|
||||||
LAST_YEAR: 'Last year',
|
LAST_YEAR: 'lastYear',
|
||||||
OLDER: 'Older',
|
OLDER: 'older',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExpenseGroup(date: Dayjs, today: Dayjs) {
|
function getExpenseGroup(date: Dayjs, today: Dayjs) {
|
||||||
@@ -76,6 +77,7 @@ export function ExpenseList({
|
|||||||
const [isFetching, setIsFetching] = useState(false)
|
const [isFetching, setIsFetching] = useState(false)
|
||||||
const [expenses, setExpenses] = useState(expensesFirstPage)
|
const [expenses, setExpenses] = useState(expensesFirstPage)
|
||||||
const { ref, inView } = useInView()
|
const { ref, inView } = useInView()
|
||||||
|
const t = useTranslations('Expenses')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeUser = localStorage.getItem('newGroup-activeUser')
|
const activeUser = localStorage.getItem('newGroup-activeUser')
|
||||||
@@ -155,7 +157,7 @@ export function ExpenseList({
|
|||||||
'text-muted-foreground text-xs pl-4 sm:pl-6 py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
|
'text-muted-foreground text-xs pl-4 sm:pl-6 py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{expenseGroup}
|
{t(`Groups.${expenseGroup}`)}
|
||||||
</div>
|
</div>
|
||||||
{groupExpenses.map((expense) => (
|
{groupExpenses.map((expense) => (
|
||||||
<ExpenseCard
|
<ExpenseCard
|
||||||
@@ -187,10 +189,10 @@ export function ExpenseList({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="px-6 text-sm py-6">
|
<p className="px-6 text-sm py-6">
|
||||||
Your group doesn’t 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
import { usePathname, useRouter } from 'next/navigation'
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -7,6 +8,7 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GroupTabs({ groupId }: Props) {
|
export function GroupTabs({ groupId }: Props) {
|
||||||
|
const t = useTranslations()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const value =
|
const value =
|
||||||
pathname.replace(/\/groups\/[^\/]+\/([^/]+).*/, '$1') || 'expenses'
|
pathname.replace(/\/groups\/[^\/]+\/([^/]+).*/, '$1') || 'expenses'
|
||||||
@@ -21,11 +23,11 @@ export function GroupTabs({ groupId }: Props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="expenses">Expenses</TabsTrigger>
|
<TabsTrigger value="expenses">{t('Expenses.title')}</TabsTrigger>
|
||||||
<TabsTrigger value="balances">Balances</TabsTrigger>
|
<TabsTrigger value="balances">{t('Balances.title')}</TabsTrigger>
|
||||||
<TabsTrigger value="stats">Stats</TabsTrigger>
|
<TabsTrigger value="stats">{t('Stats.title')}</TabsTrigger>
|
||||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
<TabsTrigger value="activity">{t('Activity.title')}</TabsTrigger>
|
||||||
<TabsTrigger value="edit">Settings</TabsTrigger>
|
<TabsTrigger value="edit">{t('Settings.title')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 doesn’t 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApplePwaSplash } from '@/app/apple-pwa-splash'
|
import { ApplePwaSplash } from '@/app/apple-pwa-splash'
|
||||||
|
import { LocaleSwitcher } from '@/components/locale-switcher'
|
||||||
import { ProgressBar } from '@/components/progress-bar'
|
import { ProgressBar } from '@/components/progress-bar'
|
||||||
import { ThemeProvider } from '@/components/theme-provider'
|
import { ThemeProvider } from '@/components/theme-provider'
|
||||||
import { ThemeToggle } from '@/components/theme-toggle'
|
import { ThemeToggle } from '@/components/theme-toggle'
|
||||||
@@ -6,6 +7,8 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { env } from '@/lib/env'
|
import { env } from '@/lib/env'
|
||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
|
import { NextIntlClientProvider, useTranslations } from 'next-intl'
|
||||||
|
import { getLocale, getMessages } from 'next-intl/server'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
@@ -59,93 +62,109 @@ export const viewport: Viewport = {
|
|||||||
themeColor: '#047857',
|
themeColor: '#047857',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
function Content({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
const t = useTranslations()
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<>
|
||||||
<ApplePwaSplash icon="/logo-with-text.png" color="#027756" />
|
<header className="fixed top-0 left-0 right-0 h-16 flex justify-between bg-white dark:bg-gray-950 bg-opacity-50 dark:bg-opacity-50 p-2 border-b backdrop-blur-sm z-50">
|
||||||
<body className="pt-16 min-h-[100dvh] flex flex-col items-stretch bg-slate-50 bg-opacity-30 dark:bg-background">
|
<Link
|
||||||
<ThemeProvider
|
className="flex items-center gap-2 hover:scale-105 transition-transform"
|
||||||
attribute="class"
|
href="/"
|
||||||
defaultTheme="system"
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
>
|
>
|
||||||
<Suspense>
|
<h1>
|
||||||
<ProgressBar />
|
<Image
|
||||||
</Suspense>
|
src="/logo-with-text.png"
|
||||||
<header className="fixed top-0 left-0 right-0 h-16 flex justify-between bg-white dark:bg-gray-950 bg-opacity-50 dark:bg-opacity-50 p-2 border-b backdrop-blur-sm z-50">
|
className="m-1 h-auto w-auto"
|
||||||
<Link
|
width={(35 * 522) / 180}
|
||||||
className="flex items-center gap-2 hover:scale-105 transition-transform"
|
height={35}
|
||||||
href="/"
|
alt="Spliit"
|
||||||
>
|
/>
|
||||||
<h1>
|
</h1>
|
||||||
<Image
|
</Link>
|
||||||
src="/logo-with-text.png"
|
<div role="navigation" aria-label="Menu" className="flex">
|
||||||
className="m-1 h-auto w-auto"
|
<ul className="flex items-center text-sm">
|
||||||
width={(35 * 522) / 180}
|
<li>
|
||||||
height={35}
|
<Button variant="ghost" asChild className="-my-3 text-primary">
|
||||||
alt="Spliit"
|
<Link href="/groups">{t('Header.groups')}</Link>
|
||||||
/>
|
</Button>
|
||||||
</h1>
|
</li>
|
||||||
|
<li>
|
||||||
|
<LocaleSwitcher />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<ThemeToggle />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col">{children}</div>
|
||||||
|
|
||||||
|
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col sm:flex-row sm:justify-between gap-4 text-xs [&_a]:underline">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<div className="sm:text-lg font-semibold text-base flex space-x-2 items-center">
|
||||||
|
<Link className="flex items-center gap-2" href="/">
|
||||||
|
<Image
|
||||||
|
src="/logo-with-text.png"
|
||||||
|
className="m-1 h-auto w-auto"
|
||||||
|
width={(35 * 522) / 180}
|
||||||
|
height={35}
|
||||||
|
alt="Spliit"
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div role="navigation" aria-label="Menu" className="flex">
|
</div>
|
||||||
<ul className="flex items-center text-sm">
|
<div className="flex flex-col space-y a--no-underline-text-white">
|
||||||
<li>
|
<span>{t('Footer.madeIn')}</span>
|
||||||
<Button
|
<span>
|
||||||
variant="ghost"
|
{t.rich('Footer.builtBy', {
|
||||||
asChild
|
author: (txt) => (
|
||||||
className="-my-3 text-primary"
|
|
||||||
>
|
|
||||||
<Link href="/groups">Groups</Link>
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<ThemeToggle />
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">{children}</div>
|
|
||||||
|
|
||||||
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col sm:flex-row sm:justify-between gap-4 text-xs [&_a]:underline">
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<div className="sm:text-lg font-semibold text-base flex space-x-2 items-center">
|
|
||||||
<Link className="flex items-center gap-2" href="/">
|
|
||||||
<Image
|
|
||||||
src="/logo-with-text.png"
|
|
||||||
className="m-1 h-auto w-auto"
|
|
||||||
width={(35 * 522) / 180}
|
|
||||||
height={35}
|
|
||||||
alt="Spliit"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col space-y a--no-underline-text-white">
|
|
||||||
<span>Made in Montréal, Québec 🇨🇦</span>
|
|
||||||
<span>
|
|
||||||
Built by{' '}
|
|
||||||
<a href="https://scastiel.dev" target="_blank" rel="noopener">
|
<a href="https://scastiel.dev" target="_blank" rel="noopener">
|
||||||
Sebastien Castiel
|
{txt}
|
||||||
</a>{' '}
|
</a>
|
||||||
and{' '}
|
),
|
||||||
|
source: (txt) => (
|
||||||
<a
|
<a
|
||||||
href="https://github.com/spliit-app/spliit/graphs/contributors"
|
href="https://github.com/spliit-app/spliit/graphs/contributors"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
contributors
|
{txt}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
),
|
||||||
</div>
|
})}
|
||||||
</div>
|
</span>
|
||||||
</footer>
|
</div>
|
||||||
<Toaster />
|
</div>
|
||||||
</ThemeProvider>
|
</footer>
|
||||||
|
<Toaster />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const locale = await getLocale()
|
||||||
|
const messages = await getMessages()
|
||||||
|
return (
|
||||||
|
<html lang={locale} suppressHydrationWarning>
|
||||||
|
<ApplePwaSplash icon="/logo-with-text.png" color="#027756" />
|
||||||
|
<body className="pt-16 min-h-[100dvh] flex flex-col items-stretch bg-slate-50 bg-opacity-30 dark:bg-background">
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<Suspense>
|
||||||
|
<ProgressBar />
|
||||||
|
</Suspense>
|
||||||
|
<Content>{children}</Content>
|
||||||
|
</ThemeProvider>
|
||||||
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { Save } from 'lucide-react'
|
import { Save } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -71,9 +72,6 @@ const enforceCurrencyPattern = (value: string) =>
|
|||||||
.replace(/#/, '.') // change back # to dot
|
.replace(/#/, '.') // change back # to dot
|
||||||
.replace(/[^-\d.]/g, '') // remove all non-numeric characters
|
.replace(/[^-\d.]/g, '') // remove all non-numeric characters
|
||||||
|
|
||||||
const capitalize = (value: string) =>
|
|
||||||
value.charAt(0).toUpperCase() + value.slice(1)
|
|
||||||
|
|
||||||
const getDefaultSplittingOptions = (group: Props['group']) => {
|
const getDefaultSplittingOptions = (group: Props['group']) => {
|
||||||
const defaultValue = {
|
const defaultValue = {
|
||||||
splitMode: 'EVENLY' as const,
|
splitMode: 'EVENLY' as const,
|
||||||
@@ -154,6 +152,7 @@ export function ExpenseForm({
|
|||||||
onDelete,
|
onDelete,
|
||||||
runtimeFeatureFlags,
|
runtimeFeatureFlags,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const t = useTranslations('ExpenseForm')
|
||||||
const isCreate = expense === undefined
|
const isCreate = expense === undefined
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const getSelectedPayer = (field?: { value: string }) => {
|
const getSelectedPayer = (field?: { value: string }) => {
|
||||||
@@ -249,7 +248,7 @@ export function ExpenseForm({
|
|||||||
Set<string>
|
Set<string>
|
||||||
>(new Set())
|
>(new Set())
|
||||||
|
|
||||||
const sExpense = isIncome ? 'income' : 'expense'
|
const sExpense = isIncome ? 'Income' : 'Expense'
|
||||||
const sPaid = isIncome ? 'received' : 'paid'
|
const sPaid = isIncome ? 'received' : 'paid'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -322,7 +321,9 @@ export function ExpenseForm({
|
|||||||
<form onSubmit={form.handleSubmit(submit)}>
|
<form onSubmit={form.handleSubmit(submit)}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{(isCreate ? 'Create ' : 'Edit ') + sExpense}</CardTitle>
|
<CardTitle>
|
||||||
|
{t(`${sExpense}.${isCreate ? 'create' : 'edit'}`)}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid sm:grid-cols-2 gap-6">
|
<CardContent className="grid sm:grid-cols-2 gap-6">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -330,10 +331,10 @@ export function ExpenseForm({
|
|||||||
name="title"
|
name="title"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="">
|
<FormItem className="">
|
||||||
<FormLabel>{capitalize(sExpense)} title</FormLabel>
|
<FormLabel>{t(`${sExpense}.TitleField.label`)}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Monday evening restaurant"
|
placeholder={t(`${sExpense}.TitleField.placeholder`)}
|
||||||
className="text-base"
|
className="text-base"
|
||||||
{...field}
|
{...field}
|
||||||
onBlur={async () => {
|
onBlur={async () => {
|
||||||
@@ -350,7 +351,7 @@ export function ExpenseForm({
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Enter a description for the {sExpense}.
|
{t(`${sExpense}.TitleField.description`)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -362,7 +363,7 @@ export function ExpenseForm({
|
|||||||
name="expenseDate"
|
name="expenseDate"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="sm:order-1">
|
<FormItem className="sm:order-1">
|
||||||
<FormLabel>{capitalize(sExpense)} date</FormLabel>
|
<FormLabel>{t(`${sExpense}.DateField.label`)}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="date-base"
|
className="date-base"
|
||||||
@@ -374,7 +375,7 @@ export function ExpenseForm({
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Enter the date the {sExpense} was {sPaid}.
|
{t(`${sExpense}.DateField.description`)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -386,7 +387,7 @@ export function ExpenseForm({
|
|||||||
name="amount"
|
name="amount"
|
||||||
render={({ field: { onChange, ...field } }) => (
|
render={({ field: { onChange, ...field } }) => (
|
||||||
<FormItem className="sm:order-3">
|
<FormItem className="sm:order-3">
|
||||||
<FormLabel>Amount</FormLabel>
|
<FormLabel>{t('amountField.label')}</FormLabel>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span>{group.currency}</span>
|
<span>{group.currency}</span>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -426,7 +427,9 @@ export function ExpenseForm({
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>This is a reimbursement</FormLabel>
|
<FormLabel>
|
||||||
|
{t('isReimbursementField.label')}
|
||||||
|
</FormLabel>
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -441,7 +444,7 @@ export function ExpenseForm({
|
|||||||
name="category"
|
name="category"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="order-3 sm:order-2">
|
<FormItem className="order-3 sm:order-2">
|
||||||
<FormLabel>Category</FormLabel>
|
<FormLabel>{t('categoryField.label')}</FormLabel>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
categories={categories}
|
categories={categories}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
@@ -451,7 +454,7 @@ export function ExpenseForm({
|
|||||||
isLoading={isCategoryLoading}
|
isLoading={isCategoryLoading}
|
||||||
/>
|
/>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Select the {sExpense} category.
|
{t(`${sExpense}.categoryFieldDescription`)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -463,7 +466,7 @@ export function ExpenseForm({
|
|||||||
name="paidBy"
|
name="paidBy"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="sm:order-5">
|
<FormItem className="sm:order-5">
|
||||||
<FormLabel>{capitalize(sPaid)} by</FormLabel>
|
<FormLabel>{t(`${sExpense}.paidByField.label`)}</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={getSelectedPayer(field)}
|
defaultValue={getSelectedPayer(field)}
|
||||||
@@ -480,7 +483,7 @@ export function ExpenseForm({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Select the participant who {sPaid} the {sExpense}.
|
{t(`${sExpense}.paidByField.description`)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -491,7 +494,7 @@ export function ExpenseForm({
|
|||||||
name="notes"
|
name="notes"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="sm:order-6">
|
<FormItem className="sm:order-6">
|
||||||
<FormLabel>Notes</FormLabel>
|
<FormLabel>{t('notesField.label')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea className="text-base" {...field} />
|
<Textarea className="text-base" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -504,7 +507,7 @@ export function ExpenseForm({
|
|||||||
<Card className="mt-4">
|
<Card className="mt-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex justify-between">
|
<CardTitle className="flex justify-between">
|
||||||
<span>{capitalize(sPaid)} for</span>
|
<span>{t(`${sExpense}.paidFor.title`)}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -530,14 +533,14 @@ export function ExpenseForm({
|
|||||||
>
|
>
|
||||||
{form.getValues().paidFor.length ===
|
{form.getValues().paidFor.length ===
|
||||||
group.participants.length ? (
|
group.participants.length ? (
|
||||||
<>Select none</>
|
<>{t('selectNone')}</>
|
||||||
) : (
|
) : (
|
||||||
<>Select all</>
|
<>{t('selectAll')}</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Select who the {sExpense} was {sPaid} for.
|
{t(`${sExpense}.paidFor.description`)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -602,7 +605,9 @@ export function ExpenseForm({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{match(form.getValues().splitMode)
|
{match(form.getValues().splitMode)
|
||||||
.with('BY_SHARES', () => <>share(s)</>)
|
.with('BY_SHARES', () => (
|
||||||
|
<>{t('shares')}</>
|
||||||
|
))
|
||||||
.with('BY_PERCENTAGE', () => <>%</>)
|
.with('BY_PERCENTAGE', () => <>%</>)
|
||||||
.with('BY_AMOUNT', () => (
|
.with('BY_AMOUNT', () => (
|
||||||
<>{group.currency}</>
|
<>{group.currency}</>
|
||||||
@@ -700,7 +705,7 @@ export function ExpenseForm({
|
|||||||
>
|
>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="link" className="-mx-4">
|
<Button variant="link" className="-mx-4">
|
||||||
Advanced splitting options…
|
{t('advancedOptions')}
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
@@ -710,7 +715,7 @@ export function ExpenseForm({
|
|||||||
name="splitMode"
|
name="splitMode"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Split mode</FormLabel>
|
<FormLabel>{t('SplitModeField.label')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -726,21 +731,23 @@ export function ExpenseForm({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="EVENLY">Evenly</SelectItem>
|
<SelectItem value="EVENLY">
|
||||||
|
{t('SplitModeField.evenly')}
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="BY_SHARES">
|
<SelectItem value="BY_SHARES">
|
||||||
Unevenly – By shares
|
{t('SplitModeField.byShares')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="BY_PERCENTAGE">
|
<SelectItem value="BY_PERCENTAGE">
|
||||||
Unevenly – By percentage
|
{t('SplitModeField.byPercentage')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="BY_AMOUNT">
|
<SelectItem value="BY_AMOUNT">
|
||||||
Unevenly – By amount
|
{t('SplitModeField.byAmount')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Select how to split the {sExpense}.
|
{t(`${sExpense}.splitModeDescription`)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -758,7 +765,7 @@ export function ExpenseForm({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
Save as default splitting options
|
{t('SplitModeField.saveAsDefault')}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -774,10 +781,10 @@ export function ExpenseForm({
|
|||||||
<Card className="mt-4">
|
<Card className="mt-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex justify-between">
|
<CardTitle className="flex justify-between">
|
||||||
<span>Attach documents</span>
|
<span>{t('attachDocuments')}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
See and attach receipts to the {sExpense}.
|
{t(`${sExpense}.attachDescription`)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -796,11 +803,9 @@ export function ExpenseForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex mt-4 gap-2">
|
<div className="flex mt-4 gap-2">
|
||||||
<SubmitButton
|
<SubmitButton loadingContent={t(isCreate ? 'creating' : 'saving')}>
|
||||||
loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
<Save className="w-4 h-4 mr-2" />
|
||||||
{isCreate ? <>Create</> : <>Save</>}
|
{t(isCreate ? 'create' : 'save')}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
{!isCreate && onDelete && (
|
{!isCreate && onDelete && (
|
||||||
<DeletePopup
|
<DeletePopup
|
||||||
@@ -808,7 +813,7 @@ export function ExpenseForm({
|
|||||||
></DeletePopup>
|
></DeletePopup>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" asChild>
|
<Button variant="ghost" asChild>
|
||||||
<Link href={`/groups/${group.id}`}>Cancel</Link>
|
<Link href={`/groups/${group.id}`}>{t('cancel')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { getGroup } from '@/lib/api'
|
|||||||
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
|
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { Save, Trash2 } from 'lucide-react'
|
import { Save, Trash2 } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useFieldArray, useForm } from 'react-hook-form'
|
import { useFieldArray, useForm } from 'react-hook-form'
|
||||||
@@ -53,6 +54,7 @@ export function GroupForm({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
protectedParticipantIds = [],
|
protectedParticipantIds = [],
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const t = useTranslations('GroupForm')
|
||||||
const form = useForm<GroupFormValues>({
|
const form = useForm<GroupFormValues>({
|
||||||
resolver: zodResolver(groupFormSchema),
|
resolver: zodResolver(groupFormSchema),
|
||||||
defaultValues: group
|
defaultValues: group
|
||||||
@@ -64,7 +66,11 @@ export function GroupForm({
|
|||||||
: {
|
: {
|
||||||
name: '',
|
name: '',
|
||||||
currency: '',
|
currency: '',
|
||||||
participants: [{ name: 'John' }, { name: 'Jane' }, { name: 'Jack' }],
|
participants: [
|
||||||
|
{ name: t('Participants.John') },
|
||||||
|
{ name: t('Participants.Jane') },
|
||||||
|
{ name: t('Participants.Jack') },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
@@ -79,10 +85,10 @@ export function GroupForm({
|
|||||||
const currentActiveUser =
|
const currentActiveUser =
|
||||||
fields.find(
|
fields.find(
|
||||||
(f) => f.id === localStorage.getItem(`${group?.id}-activeUser`),
|
(f) => f.id === localStorage.getItem(`${group?.id}-activeUser`),
|
||||||
)?.name || 'None'
|
)?.name || t('Settings.ActiveUserField.none')
|
||||||
setActiveUser(currentActiveUser)
|
setActiveUser(currentActiveUser)
|
||||||
}
|
}
|
||||||
}, [activeUser, fields, group?.id])
|
}, [t, activeUser, fields, group?.id])
|
||||||
|
|
||||||
const updateActiveUser = () => {
|
const updateActiveUser = () => {
|
||||||
if (!activeUser) return
|
if (!activeUser) return
|
||||||
@@ -111,7 +117,7 @@ export function GroupForm({
|
|||||||
>
|
>
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Group information</CardTitle>
|
<CardTitle>{t('title')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -119,16 +125,16 @@ export function GroupForm({
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Group name</FormLabel>
|
<FormLabel>{t('NameField.label')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="text-base"
|
className="text-base"
|
||||||
placeholder="Summer vacations"
|
placeholder={t('NameField.placeholder')}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Enter a name for your group.
|
{t('NameField.description')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -140,17 +146,17 @@ export function GroupForm({
|
|||||||
name="currency"
|
name="currency"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Currency symbol</FormLabel>
|
<FormLabel>{t('CurrencyField.label')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="text-base"
|
className="text-base"
|
||||||
placeholder="$, €, £…"
|
placeholder={t('CurrencyField.placeholder')}
|
||||||
max={5}
|
max={5}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
We’ll use it to display amounts.
|
{t('CurrencyField.description')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -161,10 +167,8 @@ export function GroupForm({
|
|||||||
|
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Participants</CardTitle>
|
<CardTitle>{t('Participants.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('Participants.description')}</CardDescription>
|
||||||
Enter the name for each participant
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="flex flex-col gap-2">
|
||||||
@@ -183,7 +187,7 @@ export function GroupForm({
|
|||||||
<Input
|
<Input
|
||||||
className="text-base"
|
className="text-base"
|
||||||
{...field}
|
{...field}
|
||||||
placeholder="New"
|
placeholder={t('Participants.new')}
|
||||||
/>
|
/>
|
||||||
{item.id &&
|
{item.id &&
|
||||||
protectedParticipantIds.includes(item.id) ? (
|
protectedParticipantIds.includes(item.id) ? (
|
||||||
@@ -203,8 +207,7 @@ export function GroupForm({
|
|||||||
align="end"
|
align="end"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
>
|
>
|
||||||
This participant is part of expenses, and can
|
{t('Participants.protectedParticipant')}
|
||||||
not be removed.
|
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
) : (
|
) : (
|
||||||
@@ -236,24 +239,21 @@ export function GroupForm({
|
|||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Add participant
|
{t('Participants.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Local settings</CardTitle>
|
<CardTitle>{t('Settings.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('Settings.description')}</CardDescription>
|
||||||
These settings are set per-device, and are used to customize your
|
|
||||||
experience.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
{activeUser !== null && (
|
{activeUser !== null && (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Active user</FormLabel>
|
<FormLabel>{t('Settings.ActiveUserField.label')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -262,10 +262,17 @@ export function GroupForm({
|
|||||||
defaultValue={activeUser}
|
defaultValue={activeUser}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a participant" />
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
'Settings.ActiveUserField.placeholder',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{[{ name: 'None' }, ...form.watch('participants')]
|
{[
|
||||||
|
{ name: t('Settings.ActiveUserField.none') },
|
||||||
|
...form.watch('participants'),
|
||||||
|
]
|
||||||
.filter((item) => item.name.length > 0)
|
.filter((item) => item.name.length > 0)
|
||||||
.map(({ name }) => (
|
.map(({ name }) => (
|
||||||
<SelectItem key={name} value={name}>
|
<SelectItem key={name} value={name}>
|
||||||
@@ -276,7 +283,7 @@ export function GroupForm({
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
User used as default for paying expenses.
|
{t('Settings.ActiveUserField.description')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -286,14 +293,15 @@ export function GroupForm({
|
|||||||
|
|
||||||
<div className="flex mt-4 gap-2">
|
<div className="flex mt-4 gap-2">
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
loadingContent={group ? 'Saving…' : 'Creating…'}
|
loadingContent={t(group ? 'Settings.saving' : 'Settings.creating')}
|
||||||
onClick={updateActiveUser}
|
onClick={updateActiveUser}
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4 mr-2" /> {group ? <>Save</> : <> Create</>}
|
<Save className="w-4 h-4 mr-2" />{' '}
|
||||||
|
{t(group ? 'Settings.save' : 'Settings.create')}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
{!group && (
|
{!group && (
|
||||||
<Button variant="ghost" asChild>
|
<Button variant="ghost" asChild>
|
||||||
<Link href="/groups">Cancel</Link>
|
<Link href="/groups">{t('Settings.cancel')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
33
src/components/locale-switcher.tsx
Normal file
33
src/components/locale-switcher.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { locales } from '@/i18n'
|
||||||
|
import { setUserLocale } from '@/lib/locale'
|
||||||
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
|
|
||||||
|
export function LocaleSwitcher() {
|
||||||
|
const t = useTranslations('Locale')
|
||||||
|
const locale = useLocale()
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" asChild className="-my-3 text-primary">
|
||||||
|
<span>{t(locale)}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
{locales.map((locale) => (
|
||||||
|
<DropdownMenuItem key={locale} onClick={() => setUserLocale(locale)}>
|
||||||
|
{t(locale)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
16
src/i18n.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { getRequestConfig } from 'next-intl/server'
|
||||||
|
import { getUserLocale } from './lib/locale'
|
||||||
|
|
||||||
|
export const locales = ['en-US', 'fi'] as const
|
||||||
|
export type Locale = (typeof locales)[number]
|
||||||
|
export type Locales = ReadonlyArray<Locale>
|
||||||
|
export const defaultLocale: Locale = 'en-US'
|
||||||
|
|
||||||
|
export default getRequestConfig(async () => {
|
||||||
|
const locale = await getUserLocale()
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default,
|
||||||
|
}
|
||||||
|
})
|
||||||
42
src/lib/locale.ts
Normal file
42
src/lib/locale.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -3,22 +3,13 @@ import * as z from 'zod'
|
|||||||
|
|
||||||
export const groupFormSchema = z
|
export const groupFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z
|
name: z.string().min(2, 'min2').max(50, 'max50'),
|
||||||
.string()
|
currency: z.string().min(1, 'min1').max(5, 'max5'),
|
||||||
.min(2, 'Enter at least two characters.')
|
|
||||||
.max(50, 'Enter at most 50 characters.'),
|
|
||||||
currency: z
|
|
||||||
.string()
|
|
||||||
.min(1, 'Enter at least one character.')
|
|
||||||
.max(5, 'Enter at most five characters.'),
|
|
||||||
participants: z
|
participants: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z
|
name: z.string().min(2, 'min2').max(50, 'max50'),
|
||||||
.string()
|
|
||||||
.min(2, 'Enter at least two characters.')
|
|
||||||
.max(50, 'Enter at most 50 characters.'),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.min(1),
|
.min(1),
|
||||||
@@ -29,7 +20,7 @@ export const groupFormSchema = z
|
|||||||
if (otherParticipant.name === participant.name) {
|
if (otherParticipant.name === participant.name) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
message: 'Another participant already has this name.',
|
message: 'duplicateParticipantName',
|
||||||
path: ['participants', i, 'name'],
|
path: ['participants', i, 'name'],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -42,9 +33,7 @@ export type GroupFormValues = z.infer<typeof groupFormSchema>
|
|||||||
export const expenseFormSchema = z
|
export const expenseFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
expenseDate: z.coerce.date(),
|
expenseDate: z.coerce.date(),
|
||||||
title: z
|
title: z.string({ required_error: 'titleRequired' }).min(2, 'min2'),
|
||||||
.string({ required_error: 'Please enter a title.' })
|
|
||||||
.min(2, 'Enter at least two characters.'),
|
|
||||||
category: z.coerce.number().default(0),
|
category: z.coerce.number().default(0),
|
||||||
amount: z
|
amount: z
|
||||||
.union(
|
.union(
|
||||||
@@ -55,19 +44,16 @@ export const expenseFormSchema = z
|
|||||||
if (Number.isNaN(valueAsNumber))
|
if (Number.isNaN(valueAsNumber))
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Invalid number.',
|
message: 'invalidNumber',
|
||||||
})
|
})
|
||||||
return Math.round(valueAsNumber * 100)
|
return Math.round(valueAsNumber * 100)
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{ required_error: 'You must enter an amount.' },
|
{ required_error: 'amountRequired' },
|
||||||
)
|
)
|
||||||
.refine((amount) => amount != 1, 'The amount must not be zero.')
|
.refine((amount) => amount != 1, 'amountNotZero')
|
||||||
.refine(
|
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
|
||||||
(amount) => amount <= 10_000_000_00,
|
paidBy: z.string({ required_error: 'paidByRequired' }),
|
||||||
'The amount must be lower than 10,000,000.',
|
|
||||||
),
|
|
||||||
paidBy: z.string({ required_error: 'You must select a participant.' }),
|
|
||||||
paidFor: z
|
paidFor: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -80,14 +66,14 @@ export const expenseFormSchema = z
|
|||||||
if (Number.isNaN(valueAsNumber))
|
if (Number.isNaN(valueAsNumber))
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Invalid number.',
|
message: 'invalidNumber',
|
||||||
})
|
})
|
||||||
return Math.round(valueAsNumber * 100)
|
return Math.round(valueAsNumber * 100)
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.min(1, 'The expense must be paid for at least one participant.')
|
.min(1, 'paidForMin1')
|
||||||
.superRefine((paidFor, ctx) => {
|
.superRefine((paidFor, ctx) => {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
for (const { shares } of paidFor) {
|
for (const { shares } of paidFor) {
|
||||||
@@ -95,7 +81,7 @@ export const expenseFormSchema = z
|
|||||||
if (shares < 1) {
|
if (shares < 1) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'All shares must be higher than 0.',
|
message: 'noZeroShares',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +124,7 @@ export const expenseFormSchema = z
|
|||||||
: `${((sum - expense.amount) / 100).toFixed(2)} surplus`
|
: `${((sum - expense.amount) / 100).toFixed(2)} surplus`
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: `Sum of amounts must equal the expense amount (${detail}).`,
|
message: 'amountSum',
|
||||||
path: ['paidFor'],
|
path: ['paidFor'],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -152,7 +138,7 @@ export const expenseFormSchema = z
|
|||||||
: `${((sum - 10000) / 100).toFixed(0)}% surplus`
|
: `${((sum - 10000) / 100).toFixed(0)}% surplus`
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: `Sum of percentages must equal 100 (${detail})`,
|
message: 'percentageSum',
|
||||||
path: ['paidFor'],
|
path: ['paidFor'],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user