32 Commits

Author SHA1 Message Date
Sebastien Castiel
39c1a2ffc6 Fix languages in Romanian translation 2024-10-20 11:51:04 -04:00
stefansn
f5154393e2 Add Romanian translations (#248)
* Add Romanian translations

Create ro.json.

* Add ro option.

Add ro option.

* Update ro.json

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-10-19 23:10:58 -04:00
Marco Sciuto
e9d583113a Update it-IT.json (#245) 2024-10-19 23:09:07 -04:00
Sebastien Castiel
21d0c02687 Use tRPC for expense form (#251) 2024-10-19 22:59:47 -04:00
Sebastien Castiel
2281316d58 Use tRPC for group create form (#250) 2024-10-19 21:48:17 -04:00
Sebastien Castiel
210c12b7ef Use tRPC in other group pages (#249)
* Use tRPC in group edition + group layout

* Use tRPC in group modals

* Use tRPC in group stats

* Use tRPC in group activity
2024-10-19 21:29:53 -04:00
Sebastien Castiel
66e15e419e Add tRPC, use it for group expenses, balances and information page (#246)
* Add tRPC, use it for group expense list

* Use tRPC for balances

* Use tRPC in group information + better loading states
2024-10-19 17:42:11 -04:00
Paweł Kotiuk
727803ea5c [translation] Add Polish language (#243)
* Add polish translation file

* Add polish to other translations
2024-10-14 19:19:59 -04:00
Thorsten Herfurtner
7add7efea2 Fix missing translation for expense title in expense-form when making a reinbursement (#244) 2024-10-14 19:13:10 -04:00
bitgroestl
a7c80f65c3 fix Dockerfile (#206)
Co-authored-by: samuel <samuel@t460.localdomain>
2024-10-14 19:11:50 -04:00
Serge D.
1e4edf7504 add ua-UA (#241) 2024-10-11 17:10:04 -04:00
Maxco10
24053ca5ab Update it-IT.json (#237) 2024-10-11 17:07:28 -04:00
Maxco10
343363d54f Added Italian language (#233)
* Update i18n.ts

* Update de-DE.json

* Update en-US.json

* Update es.json

* Update fi.json

* Update fr-FR.json

* Update ru-RU.json

* Update zh-CN.json

* Create it-IT.json

* Update it-IT.json

* Update it-IT.json

* Update it-IT.json

* Update it-IT.json

* Update it-IT.json

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-10-05 10:30:45 -04:00
Mert Demir
8742bd59da Fix format for integer amounts (#231)
* support both fractions and integer values

* test fractions

* prettier
2024-09-29 09:24:10 -04:00
Kostas Vrouvas
8eea062218 Fix form columns not working properly (#224) 2024-09-28 18:41:19 -04:00
Mert Demir
9a5674e239 Fix amount preview for scanned receipts (#227)
* no division of amount

* use gpt-4-turbo

* testing setup and naive test

* test multiple variants

* document

* correct locale names

* test large amounts

* test wth strings

* prettier
2024-09-28 18:39:01 -04:00
Tobias Genannt
50b3a2e431 Fix: Correctly display loaded expense (#210)
* Fix #209: Correctly display loaded expense

- Don't load default split options after displaying an existing expense
- Re-validate form after changing the "paidFor" selection.
  This fixes the error message "The expense must be paid for at least one
  participant." after clicking "Select None" and the selecting one participant.

* Fix Paid For Field reset in Edit Expense Page for split Mode 'Unevenly - By amount'

---------

Co-authored-by: partho.kunda <partho.kunda@chaldal.net>
2024-09-28 18:28:27 -04:00
Nikita Utkin
e8d46cd4f3 Add russian localization (#216) 2024-09-28 18:23:17 -04:00
Zack_Z
8f896f7412 Add Chinese translation (#215)
* Added Chinese translation

* Add home page translation

* Fix translations

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-09-28 18:18:27 -04:00
chrisschuller
504631454a feat: add German language support (#207)
* feat: add German language support

* fix: translate other locale names to German

* chore: integrate recommendations from the PR review

* i18n: add translation recommendations from the PR

* Fix translations

---------

Co-authored-by: Christian Schuller <christianschuller.biz@gmail.com>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-09-28 18:11:30 -04:00
Pau Sansa
345f3716c9 feature: add Spanish language support (#214)
* create ES i18n json

* add ES locale to i18n and existing locales

* capitalize words at es.json

* Add missing translation

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-09-28 18:06:15 -04:00
Sebastien Castiel
5fff8da08d Fix translation 2024-09-28 17:58:04 -04:00
Strekol
07e24f7fcb Add French translation (#196)
* Added french version and title/description from json messages

* Revert back default language to en-US

* Code reviewed with prettier :)

* Updated json to add information field

* Updated json to add information block (missed on previous)

* Reviewed code language

* correction traduction "groupes étoilés" en "groupes favoris"

---------

Co-authored-by: Andy Trouvé <andy@strekol.eu>
2024-09-28 17:56:55 -04:00
Sebastien Castiel
5dfe03b3f1 Make header buttons smaller (#191) 2024-08-02 12:22:39 -04:00
Sebastien Castiel
26bed11116 Update Next.js + Npm audit fix (#190)
* Audit fix

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

* add breaks to info page

* Improve UX

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-08-02 12:03:36 -04:00
Tuomas Jaakola
4f5e124ff0 Internationalization + Finnish language (#181)
* I18n with next-intl

* package-lock

* Finnish translations

* Development fix

* Use locale for positioning currency symbol

* Translations: Expenses.ActiveUserModal

* Translations: group 404

* Better translation for ExpenseCard

* Apply translations in CategorySelect search

* Fix for Finnish translation

* Translations for ExpenseDocumentsInput

* Translations for CreateFromReceipt

* Fix for Finnish translation

* Translations for schema errors

* Fix for Finnish translation

* Fixes for Finnish translations

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-08-02 11:26:23 -04:00
Miska Pajukangas
c392c06b39 feat: add auto-balancing for the amount edit (#173)
* feat: add auto-balancing for the amount edit

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

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

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

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

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

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

* Prettier

---------

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

* Prettier

---------

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

View File

@@ -1,9 +1,9 @@
FROM node:21-alpine as base
FROM node:21-alpine AS base
WORKDIR /usr/app
COPY ./package.json \
./package-lock.json \
./next.config.js \
./next.config.mjs \
./tsconfig.json \
./reset.d.ts \
./tailwind.config.js \
@@ -16,6 +16,7 @@ RUN apk add --no-cache openssl && \
npx prisma generate
COPY ./src ./src
COPY ./messages ./messages
ENV NEXT_TELEMETRY_DISABLED=1
@@ -24,21 +25,21 @@ RUN npm run build
RUN rm -r .next/cache
FROM node:21-alpine as runtime-deps
FROM node:21-alpine AS runtime-deps
WORKDIR /usr/app
COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.js ./
COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.mjs ./
COPY --from=base /usr/app/prisma ./prisma
RUN npm ci --omit=dev --omit=optional --ignore-scripts && \
npx prisma generate
FROM node:21-alpine as runner
FROM node:21-alpine AS runner
EXPOSE 3000/tcp
WORKDIR /usr/app
COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.js ./
COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.mjs ./
COPY --from=runtime-deps /usr/app/node_modules ./node_modules
COPY ./public ./public
COPY ./scripts ./scripts

18
jest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Config } from 'jest'
import nextJest from 'next/jest.js'
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
})
// Add any custom config to be passed to Jest
const config: Config = {
coverageProvider: 'v8',
testEnvironment: 'jsdom',
// Add more setup options before each test is run
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config)

401
messages/de-DE.json Normal file
View File

@@ -0,0 +1,401 @@
{
"Homepage": {
"title": "Teile <strong>Ausgaben</strong> mit <strong>Freunden & Familie</strong>",
"description": "Willkommen zu deiner neuen <strong>Spliit</strong>-Instanz!",
"button": {
"groups": "Zu den Gruppen",
"github": "GitHub"
}
},
"Header": {
"groups": "Gruppen"
},
"Footer": {
"madeIn": "Entwickelt in Montréal, Québec 🇨🇦",
"builtBy": "Erstellt von <author>Sebastien Castiel</author> und <source>Mitwirkenden</source>"
},
"Expenses": {
"title": "Ausgaben",
"description": "Hier sind die Ausgaben, die du für deine Gruppe erstellt hast.",
"create": "Ausgabe hinzufügen",
"createFirst": "Erstelle die Erste",
"noExpenses": "Deine Gruppe hat noch keine Ausgaben.",
"exportJson": "Als JSON exportieren",
"searchPlaceholder": "Suche nach einer Ausgabe…",
"ActiveUserModal": {
"title": "Wer bist du?",
"description": "Sag uns, welcher Teilnehmer du bist, um die angezeigten Informationen auf dich anzupassen.",
"nobody": "Ich will niemanden auswählen",
"save": "Änderungen speichern",
"footer": "Diese Einstellung kann später in den Gruppeneinstellungen geändert werden."
},
"Groups": {
"upcoming": "Bevorstehend",
"thisWeek": "Diese Woche",
"earlierThisMonth": "Diesen Monat",
"lastMonth": "Letzten Monat",
"earlierThisYear": "Dieses Jahr",
"lastYera": "Letztes Jahr",
"older": "Älter"
}
},
"ExpenseCard": {
"paidBy": "Gezahlt von <strong>{paidBy}</strong> für <paidFor></paidFor>",
"receivedBy": "Empfangen von <strong>{paidBy}</strong> für <paidFor></paidFor>",
"yourBalance": "Deine Bilanz:"
},
"Groups": {
"myGroups": "Meine Gruppen",
"create": "Erstellen",
"loadingRecent": "Lade letzte Gruppen…",
"NoRecent": {
"description": "Du hast in der letzten Zeit keine Gruppe besucht.",
"create": "Erstelle eine",
"orAsk": "oder bitte einen Freund, dir einen Link zu einer Existierenden zu schicken."
},
"recent": "Letzte Gruppen",
"starred": "Favorisierte Gruppen",
"archived": "Archivierte Gruppen",
"archive": "Gruppe archivieren",
"unarchive": "Gruppe wiederherstellen",
"removeRecent": "Aus letzten Gruppen entfernen",
"RecentRemovedToast": {
"title": "Gruppe wurde entfernt",
"description": "Die Gruppe wurde von deiner Liste der letzten Gruppen entfernt.",
"undoAlt": "Gruppe entfernen rückgängig machen",
"undo": "Rückgängig machen"
},
"AddByURL": {
"button": "Mit URL hinzufügen",
"title": "Gruppe mit URL hinzufügen",
"description": "Wenn eine Gruppe mit dir geteilt wurde, kannst du ihre URL hier einfügen, um sie zu deiner Liste hinzuzufügen.",
"error": "Ups, wir können die Gruppe mit der angegebenen URL nicht finden…"
},
"NotFound": {
"text": "Diese Gruppe existiert nicht.",
"link": "Gehe zu zuletzt besuchten Gruppen"
}
},
"GroupForm": {
"title": "Gruppeninformationen",
"NameField": {
"label": "Gruppenname",
"placeholder": "Sommerurlaub",
"description": "Gib deiner Gruppe einen Namen."
},
"InformationField": {
"label": "Gruppeninformationen",
"placeholder": "Welche Informationen sind relevant für Gruppenmitglieder?"
},
"CurrencyField": {
"label": "Währungssymbol",
"placeholder": "€, $, £…",
"description": "Wir benutzen es, um Beträge anzuzeigen."
},
"Participants": {
"title": "Mitglieder",
"description": "Füge einen Namen für jedes Gruppenmitglied hinzu.",
"protectedParticipant": "Dieses Mitglied ist Teil der Ausgaben und kann nicht entfernt werden.",
"new": "Neu",
"add": "Mitglied hinzufügen",
"John": "Johannes",
"Jane": "Janina",
"Jack": "Jakob"
},
"Settings": {
"title": "Lokale Einstellungen",
"description": "Dies sind Einstellungen pro Gerät, die verwendet werden, um deine Benutzererfahrung zu verbessern.",
"ActiveUserField": {
"label": "Aktiver Nutzer",
"placeholder": "Wähle ein Mitglied",
"none": "Keiner",
"description": "Standardnutzer, der die Ausgaben übernimmt."
},
"save": "Speichern",
"saving": "Speichert…",
"create": "Erstellen",
"creating": "Erstellt…",
"cancel": "Abbrechen"
}
},
"ExpenseForm": {
"Income": {
"create": "Einnahme erstellen",
"edit": "Einnahme bearbeiten",
"TitleField": {
"label": "Titel der Einnahme",
"placeholder": "Montagabend Restaurant",
"description": "Füge eine Beschreibung für die Einnahme hinzu."
},
"DateField": {
"label": "Datum der Einnahme",
"description": "Füge ein Datum hinzu für wann die Einnahme erhalten wurde."
},
"categoryFieldDescription": "Wähle die Kategorie der Einnahme.",
"paidByField": {
"label": "Empfangen von",
"description": "Wähle das Mitglied, das die Einnahme erhalten hat."
},
"paidFor": {
"title": "Empfangen für",
"description": "Wähle für wen die Einnahme empfangen wurde."
},
"splitModeDescription": "Wähle, wie die Einnahme aufgeteilt werden soll.",
"attachDescription": "Füge der Einnahme einen Beleg hinzu."
},
"Expense": {
"create": "Augabe erstellen",
"edit": "Ausgabe bearbeiten",
"TitleField": {
"label": "Titel der Ausgabe",
"placeholder": "Montagabend Restaurant",
"description": "Füge eine Beschreibung für die Ausgabe hinzu."
},
"DateField": {
"label": "Datum der Ausgabe",
"description": "Füge das Datum ein, zu dem die Ausgabe getätigt wurde."
},
"categoryFieldDescription": "Wähle eine Kategorie für die Ausgabe.",
"paidByField": {
"label": "Gezahlt von",
"description": "Wähle das Mitglied, das die Ausgabe bezahlt hat."
},
"paidFor": {
"title": "Gezahlt für",
"description": "Wähle für wen die Ausgabe gezahlt wurde."
},
"splitModeDescription": "Wähle, wie die Ausgabe aufgeteilt werden soll.",
"attachDescription": "Füge der Ausgabe einen Beleg hinzu."
},
"amountField": {
"label": "Betrag"
},
"isReimbursementField": {
"label": "Das ist eine Rückzahlung"
},
"categoryField": {
"label": "Kategorie"
},
"notesField": {
"label": "Notizen"
},
"selectNone": "Keine auswählen",
"selectAll": "Alle auswählen",
"shares": "Anteil(e)",
"advancedOptions": "Fortgeschrittene Aufteilungsoptionen…",
"SplitModeField": {
"label": "Aufteilungsart",
"evenly": "Gleich verteilt",
"byShares": "Ungleich Nach Anteilen",
"byPercentage": "Ungleich Prozentual",
"byAmount": "Ungleich Nach Betrag",
"saveAsDefault": "Als Standardoptionen zur Aufteilung speichern"
},
"DeletePopup": {
"label": "Löschen",
"title": "Diese Ausgabe löschen?",
"description": "Willst du diese Ausgabe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"yes": "Ja",
"cancel": "Abbrechen"
},
"attachDocuments": "Dokument hinzufügen",
"create": "Erstellen",
"creating": "Erstellt…",
"save": "Speichern",
"saving": "Speichert…",
"cancel": "Abbrechen",
"reimbursement": "Rückzahlung"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Die Datei ist zu groß",
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist ${size}."
},
"ErrorToast": {
"title": "Fehler beim Hochladen der Datei",
"description": "Beim Hochladen der Datei ist etwas schiefgelaufen. Versuche es später nochmal oder wähle eine andere Datei.",
"retry": "Wiederholen"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Ausgabe von Rechnungsbeleg erstellen",
"title": "Von Rechnungsbeleg erstellen",
"description": "Ausgabeninformationen von einem Foto einer Rechnung lesen.",
"body": "Lade ein Foto der Rechnung hoch und wir versuchen die Ausgabeinformationen zu extrahieren",
"selectImage": "Bild wählen…",
"titleLabel": "Titel:",
"categoryLabel": "Kategorie:",
"amountLabel": "Betrag:",
"dateLabel": "Datum:",
"editNext": "Als nächstes kannst du die Informationen zur Ausgabe editieren.",
"continue": "Weiter"
},
"unknown": "Unbekannt",
"TooBigToast": {
"title": "Die Datei ist zu groß",
"description": "Die maximale Dateigröße ist {maxSize}. Deine ist ${size}."
},
"ErrorToast": {
"title": "Fehler beim Hochladen der Datei",
"description": "Beim Hochladen der Datei ist etwas schiefgelaufen. Versuche es später nochmal oder wähle eine andere Datei.",
"retry": "Wiederholen"
}
},
"Balances": {
"title": "Bilanz",
"description": "Das sind die Beträge, die jedes Mitglied bezahlt oder empfangen hat.",
"Reimbursements": {
"title": "Vorgeschlagene Rückzahlungen",
"description": "Hier sind Vorschläge für optimierte Rückzahlungen zwischen Mitgliedern.",
"noImbursements": "Es sieht aus, als seien in der Gruppe keine Rückzahlungen nötig 😁",
"owes": "<strong>{from}</strong> schuldet <strong>{to}</strong>",
"markAsPaid": "Als gezahlt markieren"
}
},
"Stats": {
"title": "Statistiken",
"Totals": {
"title": "Gesamtausgaben",
"description": "Zusammenfassung der Ausgaben der gesamten Gruppe.",
"groupSpendings": "Gesamte Ausgaben der Gruppe",
"groupEarnings": "Gesamte Einnahmen der Gruppe",
"yourSpendings": "Deine gesamten Ausgaben",
"yourEarnings": "Deine gesamten Einnahmen",
"yourShare": "Dein gesamter Anteil"
}
},
"Activity": {
"title": "Aktivitäten",
"description": "Zusammenfassung aller Aktivitäten in dieser Gruppe.",
"noActivity": "Es gab noch keine Aktivität in dieser Gruppe.",
"someone": "Jemand",
"settingsModified": "Die Gruppeneinstellungen wurden von <strong>{participant}</strong> verändert.",
"expenseCreated": "Augabe <em>{expense}</em> wurde von <strong>{participant}</strong> erstellt.",
"expenseUpdated": "Ausgabe <em>{expense}</em> wurde von <strong>{participant}</strong> aktualisiert.",
"expenseDeleted": "Ausgabe <em>{expense}</em> wurde von <strong>{participant}</strong> gelöscht.",
"Groups": {
"today": "Heute",
"yesterday": "Gestern",
"earlierThisWeek": "Diese Woche",
"lastWeek": "Letze Woche",
"earlierThisMonth": "Diesen Monat",
"lastMonth": "Letzen Monat",
"earlierThisYear": "Dieses Jahr",
"lastYear": "Letztes Jahr",
"older": "Älter"
}
},
"Information": {
"title": "Informationen",
"description": "Nutze diesen Ort, um Informationen hinzuzufügen, die für die Gruppenmitglieder wichtig sein könnten.",
"empty": "Noch keine Gruppeninformationen vorhanden."
},
"Settings": {
"title": "Einstellungen"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Teilen",
"description": "Teile die URL, damit andere Mitglieder die Gruppe sehen und Ausgaben hinzufügen können.",
"warning": "Achtung!",
"warningHelp": "Jede person mit der Gruppen-URL kann Ausgaben sehen und editieren. Teile den Link mit Bedacht!"
},
"SchemaErrors": {
"min1": "Gib mindestens ein Zeichen ein.",
"min2": "Gib mindestens zwei Zeichen ein.",
"max5": "Gib maximal fünf Zeichen ein.",
"max50": "Gib maximal 50 Zeichen ein.",
"duplicateParticipantName": "Der Name ist bereits an ein anderes Gruppenmitglied vergeben.",
"titleRequired": "Bitte gib einen Titel an.",
"invalidNumber": "Zahl nicht valide.",
"amountRequired": "Du musst einen Betrag angeben.",
"amountNotZero": "Der Betrag darf nicht 0 sein.",
"amountTenMillion": "Der Betrag muss kleiner als 10.000.000 sein",
"paidByRequired": "Du musst ein Mitglied auswählen.",
"paidForMin1": "Die Ausgabe muss mindestens für ein Mitglied bezahlt werden.",
"noZeroShares": "Alle Anteile müssen größer als 0 sein.",
"amountSum": "Die Summe der Beträge muss dem Betrag der Ausgabe entsprechen.",
"percentageSum": "Die Summe der prozentualen Anteile muss 100 ergeben."
},
"Categories": {
"search": "Nach Kategorie suchen...",
"noCategory": "Keine Kategorie gefunden.",
"Uncategorized": {
"heading": "Nicht kategorisiert",
"General": "Allgemein",
"Payment": "Zahlung"
},
"Entertainment": {
"heading": "Vergnügen",
"Entertainment": "Vergnügen",
"Games": "Spiele",
"Movies": "Filme",
"Music": "Musik",
"Sports": "Sport"
},
"Food and Drink": {
"heading": "Essen und Trinken",
"Food and Drink": "Essen und Trinken",
"Dining Out": "Essen gehen",
"Groceries": "Lebensmittel",
"Liquor": "Alkohol"
},
"Home": {
"heading": "Zuhause",
"Home": "Zuhause",
"Electronics": "Elektronik",
"Furniture": "Möbel",
"Household Supplies": "Haushaltsgegenstände",
"Maintenance": "Wartung",
"Mortgage": "Hypothek",
"Pets": "Haustiere",
"Rent": "Miete",
"Services": "Dienstleistungen"
},
"Life": {
"heading": "Leben",
"Childcare": "Kinderversorgung",
"Clothing": "Kleidung",
"Education": "Bildung",
"Gifts": "Geschenke",
"Insurance": "Versicherung",
"Medical Expenses": "Medizinische Ausgaben",
"Taxes": "Steuern"
},
"Transportation": {
"heading": "Transport",
"Transportation": "Transport",
"Bicycle": "Fahrrad",
"Bus/Train": "Bus/Bahn",
"Car": "Auto",
"Gas/Fuel": "Tanken",
"Hotel": "Hotel",
"Parking": "Parken",
"Plane": "Flugzeug",
"Taxi": "Taxi"
},
"Utilities": {
"heading": "Versorgung",
"Utilities": "Versorgung",
"Cleaning": "Reinigung/Putzen",
"Electricity": "Strom",
"Heat/Gas": "Heizung",
"Trash": "Müll",
"TV/Phone/Internet": "TV/Internet/Telefonie",
"Water": "Wasser"
}
}
}

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

@@ -0,0 +1,401 @@
{
"Homepage": {
"title": "Share <strong>Expenses</strong> with <strong>Friends & Family</strong>",
"description": "Welcome to your new <strong>Spliit</strong> instance !",
"button": {
"groups": "Go to groups",
"github": "GitHub"
}
},
"Header": {
"groups": "Groups"
},
"Footer": {
"madeIn": "Made in Montréal, Québec 🇨🇦",
"builtBy": "Built by <author>Sebastien Castiel</author> and <source>contributors</source>"
},
"Expenses": {
"title": "Expenses",
"description": "Here are the expenses that you created for your group.",
"create": "Create expense",
"createFirst": "Create the first one",
"noExpenses": "Your group doesnt contain any expense yet.",
"exportJson": "Export to JSON",
"searchPlaceholder": "Search for an expense…",
"ActiveUserModal": {
"title": "Who are you?",
"description": "Tell us which participant you are to let us customize how the information is displayed.",
"nobody": "I dont want to select anyone",
"save": "Save changes",
"footer": "This setting can be changed later in the group settings."
},
"Groups": {
"upcoming": "Upcoming",
"thisWeek": "This week",
"earlierThisMonth": "Earlier this month",
"lastMonth": "Last month",
"earlierThisYear": "Earlier this year",
"lastYera": "Last year",
"older": "Older"
}
},
"ExpenseCard": {
"paidBy": "Paid by <strong>{paidBy}</strong> for <paidFor></paidFor>",
"receivedBy": "Received by <strong>{paidBy}</strong> for <paidFor></paidFor>",
"yourBalance": "Your balance:"
},
"Groups": {
"myGroups": "My groups",
"create": "Create",
"loadingRecent": "Loading recent groups…",
"NoRecent": {
"description": "You have not visited any group recently.",
"create": "Create one",
"orAsk": "or ask a friend to send you the link to an existing one."
},
"recent": "Recent groups",
"starred": "Starred groups",
"archived": "Archived groups",
"archive": "Archive group",
"unarchive": "Unarchive group",
"removeRecent": "Remove from recent groups",
"RecentRemovedToast": {
"title": "Group has been removed",
"description": "The group was removed from your recent groups list.",
"undoAlt": "Undo group removal",
"undo": "Undo"
},
"AddByURL": {
"button": "Add by URL",
"title": "Add a group by URL",
"description": "If a group was shared with you, you can paste its URL here to add it to your list.",
"error": "Oops, we are not able to find the group from the URL you provided…"
},
"NotFound": {
"text": "This group does not exist.",
"link": "Go to recently visited groups"
}
},
"GroupForm": {
"title": "Group information",
"NameField": {
"label": "Group name",
"placeholder": "Summer vacations",
"description": "Enter a name for your group."
},
"InformationField": {
"label": "Group information",
"placeholder": "What information is relevant to group participants?"
},
"CurrencyField": {
"label": "Currency symbol",
"placeholder": "$, €, £…",
"description": "Well use it to display amounts."
},
"Participants": {
"title": "Participants",
"description": "Enter the name for each participant.",
"protectedParticipant": "This participant is part of expenses, and can not be removed.",
"new": "New",
"add": "Add participant",
"John": "John",
"Jane": "Jane",
"Jack": "Jack"
},
"Settings": {
"title": "Local settings",
"description": "These settings are set per-device, and are used to customize your experience.",
"ActiveUserField": {
"label": "Active user",
"placeholder": "Select a participant",
"none": "None",
"description": "User used as default for paying expenses."
},
"save": "Save",
"saving": "Saving…",
"create": "Create",
"creating": "Creating…",
"cancel": "Cancel"
}
},
"ExpenseForm": {
"Income": {
"create": "Create income",
"edit": "Edit income",
"TitleField": {
"label": "Income title",
"placeholder": "Monday evening restaurant",
"description": "Enter a description for the income."
},
"DateField": {
"label": "Income date",
"description": "Enter the date the income was received."
},
"categoryFieldDescription": "Select the income category.",
"paidByField": {
"label": "Received by",
"description": "Select the participant who received the income."
},
"paidFor": {
"title": "Received for",
"description": "Select who the income was received for."
},
"splitModeDescription": "Select how to split the income.",
"attachDescription": "See and attach receipts to the income."
},
"Expense": {
"create": "Create expense",
"edit": "Edit expense",
"TitleField": {
"label": "Expense title",
"placeholder": "Monday evening restaurant",
"description": "Enter a description for the expense."
},
"DateField": {
"label": "Expense date",
"description": "Enter the date the expense was paid."
},
"categoryFieldDescription": "Select the expense category.",
"paidByField": {
"label": "Paid by",
"description": "Select the participant who paid the expense."
},
"paidFor": {
"title": "Paid for",
"description": "Select who the expense was paid for."
},
"splitModeDescription": "Select how to split the expense.",
"attachDescription": "See and attach receipts to the expense."
},
"amountField": {
"label": "Amount"
},
"isReimbursementField": {
"label": "This is a reimbursement"
},
"categoryField": {
"label": "Category"
},
"notesField": {
"label": "Notes"
},
"selectNone": "Select none",
"selectAll": "Select all",
"shares": "share(s)",
"advancedOptions": "Advanced splitting options…",
"SplitModeField": {
"label": "Split mode",
"evenly": "Evenly",
"byShares": "Unevenly By shares",
"byPercentage": "Unevenly By percentage",
"byAmount": "Unevenly By amount",
"saveAsDefault": "Save as default splitting options"
},
"DeletePopup": {
"label": "Delete",
"title": "Delete this expense?",
"description": "Do you really want to delete this expense? This action is irreversible.",
"yes": "Yes",
"cancel": "Cancel"
},
"attachDocuments": "Attach documents",
"create": "Create",
"creating": "Creating…",
"save": "Save",
"saving": "Saving…",
"cancel": "Cancel",
"reimbursement": "Reimbursement"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "The file is too big",
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
},
"ErrorToast": {
"title": "Error while uploading document",
"description": "Something wrong happened when uploading the document. Please retry later or select a different file.",
"retry": "Retry"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Create expense from receipt",
"title": "Create from receipt",
"description": "Extract the expense information from a receipt photo.",
"body": "Upload the photo of a receipt, and well scan it to extract the expense information if we can.",
"selectImage": "Select image…",
"titleLabel": "Title:",
"categoryLabel": "Category:",
"amountLabel": "Amount:",
"dateLabel": "Date:",
"editNext": "Youll be able to edit the expense information next.",
"continue": "Continue"
},
"unknown": "Unknown",
"TooBigToast": {
"title": "The file is too big",
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
},
"ErrorToast": {
"title": "Error while uploading document",
"description": "Something wrong happened when uploading the document. Please retry later or select a different file.",
"retry": "Retry"
}
},
"Balances": {
"title": "Balances",
"description": "This is the amount that each participant paid or was paid for.",
"Reimbursements": {
"title": "Suggested reimbursements",
"description": "Here are suggestions for optimized reimbursements between participants.",
"noImbursements": "It looks like your group doesnt need any reimbursement 😁",
"owes": "<strong>{from}</strong> owes <strong>{to}</strong>",
"markAsPaid": "Mark as paid"
}
},
"Stats": {
"title": "Stats",
"Totals": {
"title": "Totals",
"description": "Spending summary of the entire group.",
"groupSpendings": "Total group spendings",
"groupEarnings": "Total group earnings",
"yourSpendings": "Your total spendings",
"yourEarnings": "Your total earnings",
"yourShare": "Your total share"
}
},
"Activity": {
"title": "Activity",
"description": "Overview of all activity in this group.",
"noActivity": "There is not yet any activity in your group.",
"someone": "Someone",
"settingsModified": "Group settings were modified by <strong>{participant}</strong>.",
"expenseCreated": "Expense <em>{expense}</em> created by <strong>{participant}</strong>.",
"expenseUpdated": "Expense <em>{expense}</em> updated by <strong>{participant}</strong>.",
"expenseDeleted": "Expense <em>{expense}</em> deleted by <strong>{participant}</strong>.",
"Groups": {
"today": "Today",
"yesterday": "Yesterday",
"earlierThisWeek": "Earlier this week",
"lastWeek": "Last week",
"earlierThisMonth": "Earlier this month",
"lastMonth": "Last month",
"earlierThisYear": "Earlier this year",
"lastYear": "Last year",
"older": "Older"
}
},
"Information": {
"title": "Information",
"description": "Use this place to add any information that can be relevant to the group participants.",
"empty": "No group information yet."
},
"Settings": {
"title": "Settings"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"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"
}
}
}

401
messages/es.json Normal file
View File

@@ -0,0 +1,401 @@
{
"Homepage": {
"title": "Comparte <strong>Gastos</strong> con <strong>Amigos y Familia</strong>",
"description": "¡Bienvenido a tu nueva instancia de <strong>Spliit</strong>!",
"button": {
"groups": "Ir a grupos",
"github": "GitHub"
}
},
"Header": {
"groups": "Grupos"
},
"Footer": {
"madeIn": "Hecho en Montréal, Québec 🇨🇦",
"builtBy": "Construido por <author>Sebastien Castiel</author> y <source>colaboradores</source>"
},
"Expenses": {
"title": "Gastos",
"description": "Aqui encontraras los gastos que has creado para tu grupo.",
"create": "Crear gasto",
"createFirst": "Crea el primero",
"noExpenses": "Tu grupo aun no tiene gastos.",
"exportJson": "Exportar a JSON",
"searchPlaceholder": "Busca un gasto…",
"ActiveUserModal": {
"title": "¿Quién es usted?",
"description": "Dinos qué participante eres para que podamos personalizar cómo se muestra la información.",
"nobody": "No quiero seleccionar a nadie",
"save": "Guardar cambios",
"footer": "Esta configuración puede modificarse posteriormente en los ajustes del grupo."
},
"Groups": {
"upcoming": "Próximamente",
"thisWeek": "Esta semana",
"earlierThisMonth": "A principios de este mes",
"lastMonth": "El mes pasado",
"earlierThisYear": "A principios de este año",
"lastYera": "El año pasado",
"older": "Más antiguos"
}
},
"ExpenseCard": {
"paidBy": "Pagado por <strong>{paidBy}</strong> para <paidFor></paidFor>",
"receivedBy": "Recibido por <strong>{paidBy}</strong> para <paidFor></paidFor>",
"yourBalance": "Tu balance:"
},
"Groups": {
"myGroups": "Mis grupos",
"create": "Crear",
"loadingRecent": "Cargando grupos recientes…",
"NoRecent": {
"description": "No has visitado ningun grupo recientemente.",
"create": "Crea uno",
"orAsk": "o pídele a un amigo que te envíe el enlace a uno ya existente.."
},
"recent": "Grupos recientes",
"starred": "Grupos favoritos",
"archived": "Grupos archivados",
"archive": "Archivar grupo",
"unarchive": "Desarchivar groupo",
"removeRecent": "Remove from recent groups",
"RecentRemovedToast": {
"title": "El grupo fue eliminado",
"description": "El grupo ha sido eliminado de tu lista de grupos recientes.",
"undoAlt": "Deshacer la eliminación del grupo",
"undo": "Deshacer"
},
"AddByURL": {
"button": "Añadir mediante url",
"title": "Añadir grupo mediante url",
"description": "Si te han compartido un grupo, puedes pegar aquí su URL para añadirlo a tu lista.",
"error": "Oops, no somos capaces de encontrar el grupo desde la URL que has proporcionado..."
},
"NotFound": {
"text": "Este grupo no existe.",
"link": "Ir a los últimos grupos visitados"
}
},
"GroupForm": {
"title": "Información del grupo",
"NameField": {
"label": "Nombre del grupo",
"placeholder": "Vacaciones en Barcelona",
"description": "Inserta un nombre para tu nuevo grupo."
},
"InformationField": {
"label": "Información del grupo",
"placeholder": "Qué información es relevante para los participantes del grupo?"
},
"CurrencyField": {
"label": "Símbolo de divisa",
"placeholder": "$, €, £…",
"description": "Lo usaremos para mostrar balances."
},
"Participants": {
"title": "Participantes",
"description": "Ingresa el nombre de cada participante.",
"protectedParticipant": "Estos participantes forman parte de gastos y no pueden ser eliminados.",
"new": "Nuevo",
"add": "Añadir participante",
"John": "Juan",
"Jane": "Maria",
"Jack": "Sergio"
},
"Settings": {
"title": "Ajustes locales",
"description": "Estos ajustes se establecen por dispositivo y se utilizan para personalizar su experiencia.",
"ActiveUserField": {
"label": "Usuario activo",
"placeholder": "Selecciona un participante...",
"none": "Ninguno",
"description": "Usuario que paga los gastos por defecto."
},
"save": "Guardar",
"saving": "Guardando",
"create": "Crear",
"creating": "Creando",
"cancel": "Cancelar"
}
},
"ExpenseForm": {
"Income": {
"create": "Crear ingreso",
"edit": "Editar ingreso",
"TitleField": {
"label": "Título del ingreso",
"placeholder": "Comida Hamburgeseria",
"description": "Introduce una descripción para este ingreso."
},
"DateField": {
"label": "Fecha del ingreso",
"description": "Ingresa la fecha en que se recibio el ingreso."
},
"categoryFieldDescription": "Seleccione la categoría de ingresos.",
"paidByField": {
"label": "Recibido por",
"description": "Seleccione el participante que recibió los ingresos."
},
"paidFor": {
"title": "Recibido para for",
"description": "Seleccione para quién se recibió el ingreso."
},
"splitModeDescription": "Seleccione como quieres dividir el ingreso.",
"attachDescription": "Ver y adjuntar tickets para el ingreso."
},
"Expense": {
"create": "Crear gasto",
"edit": "Editar gasto",
"TitleField": {
"label": "Título del gasto",
"placeholder": "Monday evening restaurant",
"description": "Enter a description for the expense."
},
"DateField": {
"label": "Fecha del gasto",
"description": "Ingresa la fecha en que se recibio el gasto."
},
"categoryFieldDescription": "Select the expense category.",
"paidByField": {
"label": "Pagado por",
"description": "Seleccione el participante que pagó el gasto."
},
"paidFor": {
"title": "Pagado para",
"description": "Seleccione para quién se pagó el gasto."
},
"splitModeDescription": "Seleccione como quieres dividir el gasto.",
"attachDescription": "Ver y adjuntar tickets para el gasto."
},
"amountField": {
"label": "Cantidad"
},
"isReimbursementField": {
"label": "Esto es un reembolso"
},
"categoryField": {
"label": "Categoria"
},
"notesField": {
"label": "Notas"
},
"selectNone": "Seleccionar ninguno",
"selectAll": "Seleccionar todos",
"shares": "partes",
"advancedOptions": "Opciones avanzadas",
"SplitModeField": {
"label": "Modo de división",
"evenly": "Uniformemente",
"byShares": "Desigualmente Por partes",
"byPercentage": "Desigualmente por porcentaje",
"byAmount": "Desigualmente por cantidad",
"saveAsDefault": "Guardar como modo preferido"
},
"DeletePopup": {
"label": "Borrar",
"title": "Borrar gasto?",
"description": "Seguro que quieres borrar este gasto? Esta acción es irreversible.",
"yes": "Si",
"cancel": "Cancelar"
},
"attachDocuments": "Adjuntar documentos",
"create": "Crear",
"creating": "Creando",
"save": "Guardar",
"saving": "Guardando",
"cancel": "Cancelar",
"reimbursement": "Reembolso"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "El archivo es demasiado grande",
"description": "El tamaño máximo de archivo que puede cargar es {maxSize}. El tuyo pesa ${size}."
},
"ErrorToast": {
"title": "Error al cargar el documento",
"description": "Ha ocurrido un error al cargar el documento. Vuelva a intentarlo más tarde o seleccione otro archivo.",
"retry": "Reintentar"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Crear gasto desde ticket",
"title": "Crear desde ticket",
"description": "Extraer la información de gastos de una foto de recibo.",
"body": "Sube la foto de un recibo y lo escanearemos para extraer la información del gasto si podemos.",
"selectImage": "Seleccionar imagen…",
"titleLabel": "Titulo:",
"categoryLabel": "Categoria:",
"amountLabel": "Cantidad:",
"dateLabel": "Fecha:",
"editNext": "A continuación podrá editar la información de los gastos.",
"continue": "Continuar"
},
"unknown": "Desconocido",
"TooBigToast": {
"title": "El archivo es demasiado grande",
"description": "El tamaño máximo de archivo que puede cargar es {maxSize}. El tuyo pesa ${size}."
},
"ErrorToast": {
"title": "Error al cargar el documento",
"description": "Ha ocurrido un error al cargar el documento. Vuelva a intentarlo más tarde o seleccione otro archivo.",
"retry": "Reintentar"
}
},
"Balances": {
"title": "Balances",
"description": "Se trata del importe que ha pagado o ha recibido cada participante.",
"Reimbursements": {
"title": "Reembolsos propuestos",
"description": "He aquí algunas sugerencias para optimizar los reembolsos entre los participantes.",
"noImbursements": "Parece que tu grupo no necesita ningún reembolso 😁",
"owes": "<strong>{from}</strong> debe <strong>{to}</strong>",
"markAsPaid": "Marcar como pagado"
}
},
"Stats": {
"title": "Estadísticas",
"Totals": {
"title": "Totales",
"description": "Resumen de gastos de todo el grupo.",
"groupSpendings": "Gastos de todo el grupo",
"groupEarnings": "Ingresos de todo el grupo",
"yourSpendings": "Tus gastos totales",
"yourEarnings": "Tus ingresos totales",
"yourShare": "Tu parte final"
}
},
"Activity": {
"title": "Actividad",
"description": "Aquí encontrarás todas las actividades recientes en tu grupo.",
"noActivity": "No hay actividad reciente en este grupo.",
"someone": "Alguien",
"settingsModified": "Los ajustes del grupo fueron modificados por <strong>{participant}</strong>.",
"expenseCreated": "Gasto <em>{expense}</em> creado por <strong>{participant}</strong>.",
"expenseUpdated": "Gasto <em>{expense}</em> actualizado por <strong>{participant}</strong>.",
"expenseDeleted": "Gasto <em>{expense}</em> borrado por <strong>{participant}</strong>.",
"Groups": {
"today": "Hoy",
"yesterday": "Ayer",
"earlierThisWeek": "A principios de esta semana",
"lastWeek": "La semana pasada",
"earlierThisMonth": "A principios de este mes",
"lastMonth": "El mes pasado",
"earlierThisYear": "A principios de este año",
"lastYear": "El ultimo año",
"older": "Más antiguos"
}
},
"Information": {
"title": "Información",
"description": "Utilice este lugar para añadir cualquier información que pueda ser relevante para los participantes del grupo.",
"empty": "Aún no hay información sobre el grupo."
},
"Settings": {
"title": "Ajustes"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Compartir",
"description": "Para que otros participantes puedan ver el grupo y añadir gastos, compárteles su URL.",
"warning": "Cuidado!",
"warningHelp": "Todas las personas que tengan la URL del grupo podrán ver y editar los gastos. ¡Comparte con precaución!"
},
"SchemaErrors": {
"min1": "Introduzca al menos un carácter.",
"min2": "Introduzca al menos dos carácter.",
"max5": "Introduzca al menos cinco carácter.",
"max50": "Introduzca al menos treinta carácter.",
"duplicateParticipantName": "Otro participante ya tiene este nombre",
"titleRequired": "Por favor, introduzca un título",
"invalidNumber": "Número inválido",
"amountRequired": "Debe introducir un importe",
"amountNotZero": "El importe no debe ser cero.",
"amountTenMillion": "El importe debe ser inferior a 10.000.000.",
"paidByRequired": "Debe seleccionar un participante",
"paidForMin1": "El gasto debe ser pagado por al menos un participante",
"noZeroShares": "Todas las participaciones deben ser superiores a 0",
"amountSum": "La suma de los importes debe ser igual al importe del gasto",
"percentageSum": "Suma de porcentajes debe ser igual a 100"
},
"Categories": {
"search": "Buscar categoría...",
"noCategory": "Categoría no encontrada!",
"Uncategorized": {
"heading": "Sin categoría",
"General": "General",
"Payment": "Pago"
},
"Entertainment": {
"heading": "Ocio",
"Entertainment": "Ocio",
"Games": "Juegos",
"Movies": "Películas",
"Music": "Musica",
"Sports": "Deportes"
},
"Food and Drink": {
"heading": "Comida y bebida",
"Food and Drink": "Comida y bebida",
"Dining Out": "Comer fuera",
"Groceries": "Comestibles",
"Liquor": "Licores"
},
"Home": {
"heading": "Hogar",
"Home": "Hogar",
"Electronics": "Electrónica",
"Furniture": "Muebles",
"Household Supplies": "Suministros del hogar",
"Maintenance": "Mantenimiento",
"Mortgage": "Hipoteca",
"Pets": "Mascotas",
"Rent": "Alquiler",
"Services": "Servicios"
},
"Life": {
"heading": "Vida",
"Childcare": "Cuidado de niños",
"Clothing": "Ropa",
"Education": "Educación",
"Gifts": "Regalos",
"Insurance": "Seguro",
"Medical Expenses": "Gastos médicos",
"Taxes": "Impuestos"
},
"Transportation": {
"heading": "Transporte",
"Transportation": "Transporte",
"Bicycle": "Bicicleta",
"Bus/Train": "Autobús/Tren",
"Car": "Coche",
"Gas/Fuel": "Gasolina/Combustible",
"Hotel": "Hotel",
"Parking": "Parking",
"Plane": "Avión",
"Taxi": "Taxi"
},
"Utilities": {
"heading": "Utilidades",
"Utilities": "Utilidades",
"Cleaning": "Limpieza",
"Electricity": "Electricidad",
"Heat/Gas": "Calefacción/Gas",
"Trash": "Basura",
"TV/Phone/Internet": "TV/Teléfono/Internet",
"Water": "Agua"
}
}
}

401
messages/fi.json Normal file
View File

@@ -0,0 +1,401 @@
{
"Homepage": {
"title": "Jaa kulut ystävien ja perheen kanssa",
"description": "Tervetuloa uuteen Spliit-instanssiisi!",
"button": {
"groups": "Siirry ryhmiin",
"github": "GitHub"
}
},
"Header": {
"groups": "Ryhmät"
},
"Footer": {
"madeIn": "Made in Montréal, Québec 🇨🇦",
"builtBy": "Tekijät: <author>Sebastien Castiel</author> ja <source>muut osallistujat</source>"
},
"Expenses": {
"title": "Kulut",
"description": "Tässä ovat ryhmässä luodut kulut.",
"create": "Lisää kulu",
"createFirst": "Lisää ensimmäinen kulu",
"noExpenses": "Ryhmälläsi ei ole vielä yhtään kulua.",
"exportJson": "Vie JSON-tiedostoon",
"searchPlaceholder": "Etsi kulua…",
"ActiveUserModal": {
"title": "Kuka olet?",
"description": "Valitse kuka osallistujista olet, jotta tiedot näkyvät oikein.",
"nobody": "En halua valita ketään",
"save": "Tallenna muutokset",
"footer": "Tämän asetuksen voi vaihtaa myöhemmin ryhmän asetuksista."
},
"Groups": {
"upcoming": "Tulevat",
"thisWeek": "Tällä viikolla",
"earlierThisMonth": "Aikaisemmin tässä kuussa",
"lastMonth": "Viime kuussa",
"earlierThisYear": "Aikaisemmin tänä vuonna",
"lastYear": "Viime vuonna",
"older": "Vanhemmat"
}
},
"ExpenseCard": {
"paidBy": "<strong>{paidBy}</strong> maksoi {forCount, plural, =1 {henkilön} other {henkilöiden}} <paidFor></paidFor> puolesta",
"receivedBy": "<strong>{paidBy}</strong> sai rahaa {forCount, plural, =1 {henkilön} other {henkilöiden}} <paidFor></paidFor> puolesta",
"yourBalance": "Saldosi:"
},
"Groups": {
"myGroups": "Omat ryhmät",
"create": "Luo ryhmä",
"loadingRecent": "Ladataan äskettäisiä ryhmiä…",
"NoRecent": {
"description": "Et ole ollut missään ryhmässä äskettäin.",
"create": "Luo uusi ryhmä",
"orAsk": "tai pyydä ystävää lähettämään linkki olemassaolevaan ryhmään."
},
"recent": "Äskettäiset",
"starred": "Suosikit",
"archived": "Arkistoidut",
"archive": "Arkistoi ryhmä",
"unarchive": "Palauta ryhmä arkistosta",
"removeRecent": "Poista äskettäisistä",
"RecentRemovedToast": {
"title": "Ryhmä poistettu",
"description": "Ryhmä poistettu äskettäisten listaltasi.",
"undoAlt": "Peruuta ryhmän poisto",
"undo": "Peruuta"
},
"AddByURL": {
"button": "Lisää URLilla",
"title": "Lisää ryhmä URL-osoitteella",
"description": "Jos ryhmä on jaettu sinulle, voit lisätä sen listaasi liittämällä URL-osoitteen tähän.",
"error": "Hups, emme löytäneet ryhmää antamastasi URL-osoitteesta…"
},
"NotFound": {
"text": "Tätä ryhmää ei löydy.",
"link": "Siirry äskettäisiin ryhmiin"
}
},
"GroupForm": {
"title": "Ryhmän tiedot",
"NameField": {
"label": "Ryhmän nimi",
"placeholder": "Kesälomareissu",
"description": "Syötä ryhmäsi nimi."
},
"InformationField": {
"label": "Ryhmän tiedot",
"placeholder": "Mitkä tiedot ovat merkityksellisiä ryhmän osallistujille?"
},
"CurrencyField": {
"label": "Valuuttamerkki",
"placeholder": "$, €, £…",
"description": "Näytetään rahasummien yhteydessä."
},
"Participants": {
"title": "Osallistujat",
"description": "Syötä jokaisen osallistujan nimi.",
"protectedParticipant": "Tätä osallistujaa ei voida poistaa, koska hän osallistuu kuluihin.",
"add": "Lisää osallistuja",
"new": "Uusi",
"John": "Antti",
"Jane": "Laura",
"Jack": "Jussi"
},
"Settings": {
"title": "Paikalliset asetukset",
"description": "Nämä asetukset ovat laitekohtaisia. Voit muokata niillä käytettävyyttä.",
"ActiveUserField": {
"label": "Aktiivinen käyttäjä",
"placeholder": "Valitse osallistuja",
"none": "Ei kukaan",
"description": "Käytetään kulujen oletusmaksajana."
},
"save": "Tallenna",
"saving": "Tallennetaan…",
"create": "Luo ryhmä",
"creating": "Luodaan…",
"cancel": "Peruuta"
}
},
"ExpenseForm": {
"Income": {
"create": "Lisää tulo",
"edit": "Muokkaa tuloa",
"TitleField": {
"label": "Otsikko",
"placeholder": "Maanantain ravintola",
"description": "Anna lyhyt kuvaus tulolle."
},
"DateField": {
"label": "Päivä",
"description": "Valitse päivä jolloin tulo saatiin."
},
"categoryFieldDescription": "Valitse tulokategoria.",
"paidByField": {
"label": "Vastaanottaja",
"description": "Valitse kuka vastaanotti tulon."
},
"paidFor": {
"title": "Tulon jakaminen",
"description": "Valitse kenelle tulo jaetaan."
},
"splitModeDescription": "Valitse miten tulo jaetaan osallistujien kesken.",
"attachDescription": "Katso ja liitä tuloon liittyviä kuitteja."
},
"Expense": {
"create": "Lisää kulu",
"edit": "Muokkaa kulua",
"TitleField": {
"label": "Otsikko",
"placeholder": "Maanantain ravintola",
"description": "Anna lyhyt kuvaus kululle."
},
"DateField": {
"label": "Päivä",
"description": "Valitse päivä jolloin kulu maksettiin."
},
"categoryFieldDescription": "Valitse kulukategoria.",
"paidByField": {
"label": "Maksaja",
"description": "Valitse kuka maksoi kulun."
},
"paidFor": {
"title": "Kulun jakaminen",
"description": "Valitse ketkä osallistuvat kuluun."
},
"splitModeDescription": "Valitse miten kulu jaetaan osallistujien kesken.",
"attachDescription": "Katso ja liitä kuluun liittyviä kuitteja."
},
"amountField": {
"label": "Summa"
},
"isReimbursementField": {
"label": "Tämä on velanmaksu"
},
"categoryField": {
"label": "Kategoria"
},
"notesField": {
"label": "Muistiinpanot"
},
"selectNone": "Tyhjennä valinnat",
"selectAll": "Valitse kaikki",
"shares": "osuutta",
"advancedOptions": "Lisäasetuksia jakamiseen…",
"SplitModeField": {
"label": "Jakamistapa",
"evenly": "Tasan",
"byShares": "Epätasan osuuksien mukaan",
"byPercentage": "Epätasan prosenttien mukaan",
"byAmount": "Epätasan summan mukaan",
"saveAsDefault": "Tallenna oletustavaksi"
},
"DeletePopup": {
"label": "Poista",
"title": "Poistetaanko tämä kulu?",
"description": "Haluatko varmasti poistaa tämän kulun? Poistoa ei voi peruuttaa.",
"yes": "Kyllä",
"cancel": "Peruuta"
},
"attachDocuments": "Liitä dokumenttejä",
"create": "Lisää kulu",
"creating": "Luodaan kulua…",
"save": "Tallenna",
"saving": "Tallennetaan…",
"cancel": "Peruuta",
"reimbursement": "Velanmaksu"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Tiedosto on liian suuri",
"description": "Maksimikoko ladattavalle tiedostolle on {maxSize}. Tiedostosi on ${size}."
},
"ErrorToast": {
"title": "Virhe tiedostoa ladattaessa",
"description": "Jokin meni vikaan dokumentin lataamisessa. Yritä myöhemmin uudelleen tai valitse toinen tiedosto.",
"retry": "Yritä uudelleen"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Luo kulu kuitista",
"title": "Luo kuitista",
"description": "Lue kuitin valokuvasta kulun tiedot.",
"body": "Lataa kuitista valokuva. Siitä skannataan tiedot kulua varten.",
"selectImage": "Valitse kuva…",
"titleLabel": "Otsikko:",
"categoryLabel": "Kategoria:",
"amountLabel": "Summa:",
"dateLabel": "Päivä:",
"editNext": "Voit muokata kulun tietoja seuraavaksi.",
"continue": "Jatka"
},
"unknown": "Unknown",
"TooBigToast": {
"title": "The file is too big",
"description": "The maximum file size you can upload is {maxSize}. Yours is ${size}."
},
"ErrorToast": {
"title": "Error while uploading document",
"description": "Something wrong happened when uploading the document. Please retry later or select a different file.",
"retry": "Retry"
}
},
"Balances": {
"title": "Saldo",
"description": "Osallistujien saatavat tai velat.",
"Reimbursements": {
"title": "Maksuehdotus",
"description": "Optimoitu ehdotus kuka maksaa kenellekin.",
"noImbursements": "Näyttää siltä, että kaikki ovat sujut 😁",
"owes": "<strong>{from}</strong> maksaa henkilölle <strong>{to}</strong>",
"markAsPaid": "Merkitse maksetuksi"
}
},
"Stats": {
"title": "Tilastot",
"Totals": {
"title": "Yhteenveto",
"description": "Koko ryhmän kulut.",
"groupSpendings": "Koko ryhmän kulutus",
"groupEarnings": "Koko ryhmän saatavat",
"yourSpendings": "Kulutuksesi",
"yourEarnings": "Saatavasi",
"yourShare": "Osuutesi"
}
},
"Activity": {
"title": "Tapahtumat",
"description": "Yleisnäkymä ryhmän kaikista tapahtumista.",
"noActivity": "Ryhmässäsi ei ole vielä tapahtumia.",
"someone": "Tuntematon",
"settingsModified": "<strong>{participant}</strong> muokkasi ryhmän asetuksia.",
"expenseCreated": "<strong>{participant}</strong> lisäsi kulun <em>{expense}</em>.",
"expenseUpdated": "<strong>{participant}</strong> muokkasi kulua <em>{expense}</em>.",
"expenseDeleted": "<strong>{participant}</strong> poisti kulun <em>{expense}</em>.",
"Groups": {
"today": "Tänään",
"yesterday": "Eilen",
"earlierThisWeek": "Tällä viikolla",
"lastWeek": "Viime viikolla",
"earlierThisMonth": "Tässä kuussa",
"lastMonth": "Viime kuussa",
"earlierThisYear": "Tänä vuonna",
"lastYear": "Viime vuonna",
"older": "Vanhemmat"
}
},
"Information": {
"title": "Tiedot",
"description": "Käytä tätä paikkaa lisätäksesi kaikki tiedot, joilla voi olla merkitystä ryhmän osallistujille.",
"empty": "Ryhmätietoja ei vielä ole."
},
"Settings": {
"title": "Asetukset"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"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"
}
}
}

401
messages/fr-FR.json Normal file
View File

@@ -0,0 +1,401 @@
{
"Homepage": {
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis</strong> & <strong>votre famille :)</strong>",
"description": "Bienvenue sur votre instance <strong>Spliit</strong> !",
"button": {
"groups": "Accéder aux groupes",
"github": "GitHub"
}
},
"Header": {
"groups": "Groupes"
},
"Footer": {
"madeIn": "Made in Montréal, Québec 🇨🇦",
"builtBy": "Développé par <author>Sebastien Castiel</author> et <source>contributeurs</source>"
},
"Expenses": {
"title": "Dépenses",
"description": "Voici les dépenses que vous avez créées pour votre groupe.",
"create": "Créer une dépense",
"createFirst": "Créer la première :)",
"noExpenses": "Votre groupe n'a effectué aucune dépense pour le moment.",
"exportJson": "Exporter en JSON",
"searchPlaceholder": "Rechercher une dépense…",
"ActiveUserModal": {
"title": "Qui êtes-vous ?",
"description": "Dites-nous quel participant vous êtes pour personnaliser l'affichage des informations.",
"nobody": "Je ne veux sélectionner personne",
"save": "Sauvegarder les modifications",
"footer": "Ce paramètre peut être modifié plus tard dans les paramètres du groupe."
},
"Groups": {
"upcoming": "À venir",
"thisWeek": "Cette semaine",
"earlierThisMonth": "Plus tôt ce mois-ci",
"lastMonth": "Le mois dernier",
"earlierThisYear": "Plus tôt cette année",
"lastYera": "L'année dernière",
"older": "Plus ancien"
}
},
"ExpenseCard": {
"paidBy": "Payé par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
"receivedBy": "Reçu par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
"yourBalance": "Votre solde :"
},
"Groups": {
"myGroups": "Mes groupes",
"create": "Créer",
"loadingRecent": "Chargement des groupes récents…",
"NoRecent": {
"description": "Vous n'avez visité aucun groupe récemment.",
"create": "Créer un groupe",
"orAsk": "ou demandez à un ami de vous envoyer le lien d'un groupe existant."
},
"recent": "Groupes récents",
"starred": "Groupes favoris",
"archived": "Groupes archivés",
"archive": "Archiver le groupe",
"unarchive": "Désarchiver le groupe",
"removeRecent": "Supprimer des groupes récents",
"RecentRemovedToast": {
"title": "Le groupe a été supprimé",
"description": "Le groupe a été supprimé de votre liste de groupes récents.",
"undoAlt": "Annuler la suppression du groupe",
"undo": "Annuler"
},
"AddByURL": {
"button": "Ajouter par URL",
"title": "Ajouter un groupe par URL",
"description": "Si un groupe a été partagé avec vous, vous pouvez coller son URL ici pour l'ajouter à votre liste.",
"error": "Oups, nous ne pouvons pas trouver le groupe à partir de l'URL que vous avez fournie…"
},
"NotFound": {
"text": "Ce groupe n'existe pas.",
"link": "Aller aux groupes récemment visités"
}
},
"GroupForm": {
"title": "Informations sur le groupe",
"NameField": {
"label": "Nom du groupe",
"placeholder": "Vacances d'été",
"description": "Entrez un nom pour votre groupe."
},
"InformationField": {
"label": "Informations sur le groupe",
"placeholder": "Quelles informations sont pertinentes pour les participants du groupe ?"
},
"CurrencyField": {
"label": "Symbole monétaire",
"placeholder": "$, €, £…",
"description": "Nous l'utiliserons pour afficher les montants."
},
"Participants": {
"title": "Participants",
"description": "Entrez le nom de chaque participant.",
"protectedParticipant": "Ce participant fait partie des dépenses et ne peut pas être supprimé.",
"new": "Nouveau",
"add": "Ajouter un participant",
"John": "John",
"Jane": "Jane",
"Jack": "Jack"
},
"Settings": {
"title": "Paramètres locaux",
"description": "Ces paramètres sont définis par appareil et sont utilisés pour personnaliser votre expérience.",
"ActiveUserField": {
"label": "Utilisateur actif",
"placeholder": "Sélectionner un participant",
"none": "Aucun",
"description": "Utilisateur utilisé comme défaut pour payer les dépenses."
},
"save": "Sauvegarder",
"saving": "Sauvegarde…",
"create": "Créer",
"creating": "Création…",
"cancel": "Annuler"
}
},
"ExpenseForm": {
"Income": {
"create": "Créer un revenu",
"edit": "Modifier le revenu",
"TitleField": {
"label": "Titre du revenu",
"placeholder": "Restaurant du lundi soir",
"description": "Entrez une description pour le revenu."
},
"DateField": {
"label": "Date du revenu",
"description": "Entrez la date à laquelle le revenu a été reçu."
},
"categoryFieldDescription": "Sélectionnez la catégorie de revenu.",
"paidByField": {
"label": "Reçu par",
"description": "Sélectionnez le participant qui a reçu le revenu."
},
"paidFor": {
"title": "Reçu pour",
"description": "Sélectionnez pour qui le revenu a été reçu."
},
"splitModeDescription": "Sélectionnez comment diviser le revenu.",
"attachDescription": "Voir et joindre des reçus au revenu."
},
"Expense": {
"create": "Créer une dépense",
"edit": "Modifier la dépense",
"TitleField": {
"label": "Titre de la dépense",
"placeholder": "Restaurant du lundi soir",
"description": "Entrez une description pour la dépense."
},
"DateField": {
"label": "Date de la dépense",
"description": "Entrez la date à laquelle la dépense a été payée."
},
"categoryFieldDescription": "Sélectionnez la catégorie de dépense.",
"paidByField": {
"label": "Payé par",
"description": "Sélectionnez le participant qui a réglé la dépense."
},
"paidFor": {
"title": "Payé pour",
"description": "Sélectionnez les participants concernés"
},
"splitModeDescription": "Sélectionnez comment diviser la dépense.",
"attachDescription": "Voir et joindre des reçus à la dépense."
},
"amountField": {
"label": "Montant"
},
"isReimbursementField": {
"label": "C'est un remboursement"
},
"categoryField": {
"label": "Catégorie"
},
"notesField": {
"label": "Notes"
},
"selectNone": "Tout désélectionner",
"selectAll": "Tout sélectionner",
"shares": "part(s)",
"advancedOptions": "Options de répartition avancées…",
"SplitModeField": {
"label": "Mode de répartition",
"evenly": "Également",
"byShares": "Inégalement Par parts",
"byPercentage": "Inégalement Par pourcentage",
"byAmount": "Inégalement Par montant",
"saveAsDefault": "Enregistrer comme options de répartition par défaut"
},
"DeletePopup": {
"label": "Supprimer",
"title": "Supprimer cette dépense ?",
"description": "Voulez-vous vraiment supprimer cette dépense ? Cette action est irréversible.",
"yes": "Oui",
"cancel": "Annuler"
},
"attachDocuments": "Joindre des documents",
"create": "Créer",
"creating": "Création…",
"save": "Sauvegarder",
"saving": "Sauvegarde…",
"cancel": "Annuler",
"reimbursement": "Remboursement"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Le fichier est trop grand",
"description": "La taille maximale du fichier que vous pouvez télécharger est {maxSize}. La vôtre est ${size}."
},
"ErrorToast": {
"title": "Erreur lors du téléchargement du document",
"description": "Un problème est survenu lors du téléchargement du document. Veuillez réessayer plus tard ou sélectionner un fichier différent.",
"retry": "Réessayer"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Créer une dépense à partir du reçu",
"title": "Créer à partir du reçu",
"description": "Extraire les informations de la dépense à partir d'une photo de reçu.",
"body": "Téléchargez la photo d'un reçu, et nous l'analyserons pour extraire les informations de la dépense si possible.",
"selectImage": "Sélectionner une image…",
"titleLabel": "Titre :",
"categoryLabel": "Catégorie :",
"amountLabel": "Montant :",
"dateLabel": "Date :",
"editNext": "Vous pourrez modifier les informations de la dépense ensuite.",
"continue": "Continuer"
},
"unknown": "Inconnu",
"TooBigToast": {
"title": "Le fichier est trop grand",
"description": "La taille maximale du fichier que vous pouvez télécharger est {maxSize}. La vôtre est ${size}."
},
"ErrorToast": {
"title": "Erreur lors du téléchargement du document",
"description": "Un problème est survenu lors du téléchargement du document. Veuillez réessayer plus tard ou sélectionner un fichier différent.",
"retry": "Réessayer"
}
},
"Balances": {
"title": "Équilibres",
"description": "Voici le montant que chaque participant a payé ou doit rembourser.",
"Reimbursements": {
"title": "Remboursements suggérés",
"description": "Voici des suggestions pour des remboursements optimisés entre les participants.",
"noImbursements": "Les dépenses effectuées ne nécessitent pas d'équilibrage 😁",
"owes": "<strong>{from}</strong> doit à <strong>{to}</strong>",
"markAsPaid": "Marquer comme payé"
}
},
"Stats": {
"title": "Statistiques",
"Totals": {
"title": "Totaux",
"description": "Résumé des dépenses du groupe entier.",
"groupSpendings": "Total des dépenses du groupe",
"groupEarnings": "Total des revenus du groupe",
"yourSpendings": "Vos dépenses totales",
"yourEarnings": "Vos revenus totaux",
"yourShare": "Votre part totale"
}
},
"Activity": {
"title": "Activité",
"description": "Vue d'ensemble de toute l'activité dans ce groupe.",
"noActivity": "Il n'y a pas encore d'activité dans votre groupe.",
"someone": "Quelqu'un",
"settingsModified": "Les paramètres du groupe ont été modifiés par <strong>{participant}</strong>.",
"expenseCreated": "Dépense <em>{expense}</em> créée par <strong>{participant}</strong>.",
"expenseUpdated": "Dépense <em>{expense}</em> mise à jour par <strong>{participant}</strong>.",
"expenseDeleted": "Dépense <em>{expense}</em> supprimée par <strong>{participant}</strong>.",
"Groups": {
"today": "Aujourd'hui",
"yesterday": "Hier",
"earlierThisWeek": "Plus tôt cette semaine",
"lastWeek": "La semaine dernière",
"earlierThisMonth": "Plus tôt ce mois-ci",
"lastMonth": "Le mois dernier",
"earlierThisYear": "Plus tôt cette année",
"lastYear": "L'année dernière",
"older": "Plus ancien"
}
},
"Information": {
"title": "Information",
"description": "Utilisez cet espace pour ajouter toute information qui pourrait être pertinente pour les participants du groupe.",
"empty": "Aucune information pour le moment."
},
"Settings": {
"title": "Paramètres"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Partager",
"description": "Pour que d'autres participants puissent voir le groupe et ajouter des dépenses, partagez son URL avec eux.",
"warning": "Avertissement !",
"warningHelp": "Toute personne ayant l'URL du groupe pourra voir et modifier les dépenses. Partagez avec prudence !"
},
"SchemaErrors": {
"min1": "Entrez au moins un caractère.",
"min2": "Entrez au moins deux caractères.",
"max5": "Entrez au maximum cinq caractères.",
"max50": "Entrez au maximum 50 caractères.",
"duplicateParticipantName": "Un autre participant a déjà ce nom.",
"titleRequired": "Veuillez entrer un titre.",
"invalidNumber": "Nombre invalide.",
"amountRequired": "Vous devez entrer un montant.",
"amountNotZero": "Le montant ne doit pas être zéro.",
"amountTenMillion": "Le montant doit être inférieur à 10 000 000.",
"paidByRequired": "Vous devez sélectionner un participant.",
"paidForMin1": "La dépense doit concerner au moins un participant.",
"noZeroShares": "Toutes les parts doivent être supérieures à 0.",
"amountSum": "La somme des montants doit être égale au montant de la dépense.",
"percentageSum": "La somme des pourcentages doit être égale à 100."
},
"Categories": {
"search": "Rechercher une catégorie…",
"noCategory": "Aucune catégorie trouvée.",
"Uncategorized": {
"heading": "Non classé",
"General": "Général",
"Payment": "Paiement"
},
"Entertainment": {
"heading": "Divertissement",
"Entertainment": "Divertissement",
"Games": "Jeux",
"Movies": "Films",
"Music": "Musique",
"Sports": "Sports"
},
"Food and Drink": {
"heading": "Nourriture et boissons",
"Food and Drink": "Nourriture et boissons",
"Dining Out": "Repas au restaurant",
"Groceries": "Épicerie",
"Liquor": "Alcool"
},
"Home": {
"heading": "Maison",
"Home": "Maison",
"Electronics": "Électronique",
"Furniture": "Mobilier",
"Household Supplies": "Fournitures ménagères",
"Maintenance": "Entretien",
"Mortgage": "Hypothèque",
"Pets": "Animaux",
"Rent": "Loyer",
"Services": "Services"
},
"Life": {
"heading": "Vie",
"Childcare": "Garde d'enfants",
"Clothing": "Vêtements",
"Education": "Éducation",
"Gifts": "Cadeaux",
"Insurance": "Assurance",
"Medical Expenses": "Dépenses médicales",
"Taxes": "Impôts"
},
"Transportation": {
"heading": "Transport",
"Transportation": "Transport",
"Bicycle": "Bicyclette",
"Bus/Train": "Bus/Train",
"Car": "Voiture",
"Gas/Fuel": "Essence/Carburant",
"Hotel": "Hôtel",
"Parking": "Parking",
"Plane": "Avion",
"Taxi": "Taxi"
},
"Utilities": {
"heading": "Services publics",
"Utilities": "Services publics",
"Cleaning": "Nettoyage",
"Electricity": "Électricité",
"Heat/Gas": "Chauffage/Gaz",
"Trash": "Poubelle",
"TV/Phone/Internet": "TV/Téléphone/Internet",
"Water": "Eau"
}
}
}

401
messages/it-IT.json Normal file
View File

@@ -0,0 +1,401 @@
{
"Homepage": {
"title": "Condividi <strong>Spese</strong> con <strong>Amici & Familiari</strong>",
"description": "Benvenuto nella tua nuova instanza di <strong>Spliit</strong>!",
"button": {
"groups": "Vai ai gruppi",
"github": "GitHub"
}
},
"Header": {
"groups": "Gruppi"
},
"Footer": {
"madeIn": "Made in Montréal, Québec 🇨🇦",
"builtBy": "Built by <author>Sebastien Castiel</author> and <source>contributors</source>"
},
"Expenses": {
"title": "Spese",
"description": "Ecco le spese che hai creato per il tuo gruppo.",
"create": "Crea spesa",
"createFirst": "Crea la prima",
"noExpenses": "Il tuo gruppo non contiene ancora spese.",
"exportJson": "Esporta file JSON",
"searchPlaceholder": "Cerca una spesa…",
"ActiveUserModal": {
"title": "Chi sei?",
"description": "Dicci quale partecipante sei per consentirci di personalizzare la modalità di visualizzazione delle informazioni.",
"nobody": "Non voglio selezionare nessuno",
"save": "Salva cambiamenti",
"footer": "Questa impostazione può essere modificata successivamente nelle impostazioni del gruppo."
},
"Groups": {
"upcoming": "In arrivo",
"thisWeek": "Questa settimana",
"earlierThisMonth": "All'inizio di questo mese",
"lastMonth": "Ultimo mese",
"earlierThisYear": "All'inizio di quest'anno",
"lastYera": "Ultimo anno",
"older": "Più vecchio"
}
},
"ExpenseCard": {
"paidBy": "Pagato da <strong>{paidBy}</strong> per <paidFor></paidFor>",
"receivedBy": "Ricevuto da <strong>{paidBy}</strong> per <paidFor></paidFor>",
"yourBalance": "Il tuo bilancio:"
},
"Groups": {
"myGroups": "I miei gruppi",
"create": "Crea",
"loadingRecent": "Caricamento gruppi recenti…",
"NoRecent": {
"description": "Non hai visitato nessun gruppo di recente.",
"create": "Creane una",
"orAsk": "oppure chiedi a un amico di inviarti il collegamento a uno esistente."
},
"recent": "Gruppi recenti",
"starred": "Gruppi speciali",
"archived": "Gruppi archiviati",
"archive": "Archivia gruppo",
"unarchive": "Rimuovi il gruppo dall'archivio",
"removeRecent": "Rimuovi dai gruppi recenti",
"RecentRemovedToast": {
"title": "Il gruppo è stato rimosso",
"description": "Il gruppo è stato rimosso dall'elenco dei gruppi recenti.",
"undoAlt": "Annulla la rimozione del gruppo",
"undo": "Annulla"
},
"AddByURL": {
"button": "Aggiungi tramite URL",
"title": "Aggiungi un gruppo tramite URL",
"description": "Se un gruppo è stato condiviso con te, puoi incollare qui il suo URL per aggiungerlo al tuo elenco.",
"error": "Spiacenti, non siamo in grado di trovare il gruppo dall'URL che hai fornito..."
},
"NotFound": {
"text": "Questo gruppo non esiste.",
"link": "Vai ai gruppi visitati di recente"
}
},
"GroupForm": {
"title": "Informazioni del gruppo",
"NameField": {
"label": "Nome del gruppo",
"placeholder": "Vacanze estive",
"description": "Inserisci il nome del gruppo."
},
"InformationField": {
"label": "Informazioni del gruppo",
"placeholder": "Quali informazioni sono rilevanti per i partecipanti al gruppo?"
},
"CurrencyField": {
"label": "Simbolo valuta",
"placeholder": "$, €, £…",
"description": "Lo useremo per visualizzare gli importi."
},
"Participants": {
"title": "Partecipanti",
"description": "Immettere il nome per ciascun partecipante.",
"protectedParticipant": "Questo partecipante fa parte delle spese e non può essere rimosso.",
"new": "Nuovo",
"add": "Aggiungi partecipante",
"John": "John",
"Jane": "Jane",
"Jack": "Jack"
},
"Settings": {
"title": "Impostazioni locali",
"description": "Queste impostazioni sono impostate per dispositivo e vengono utilizzate per personalizzare la tua esperienza.",
"ActiveUserField": {
"label": "Utente attivo",
"placeholder": "Seleziona un partecipante",
"none": "Nessuno",
"description": "Utente utilizzato come predefinito per il pagamento delle spese."
},
"save": "Salva",
"saving": "Salvataggio…",
"create": "Crea",
"creating": "Sto creando…",
"cancel": "Annulla"
}
},
"ExpenseForm": {
"Income": {
"create": "Crea entrata",
"edit": "Modifica entrata",
"TitleField": {
"label": "Titolo entrata",
"placeholder": "Ristorante del lunedì sera",
"description": "Inserisci una descrizione per l'entrata."
},
"DateField": {
"label": "Data entrata",
"description": "Inserisci la data in cui è stato ricevuta l'entrata."
},
"categoryFieldDescription": "Seleziona categoria entrata.",
"paidByField": {
"label": "Ricevuto da",
"description": "Seleziona partecipante che ha ricevuto l'entrata."
},
"paidFor": {
"title": "Ricevuto per",
"description": "Seleziona per chi è stato ricevuta l'entrata."
},
"splitModeDescription": "Seleziona come dividere l'entrata.",
"attachDescription": "Vedi allegati entrata."
},
"Expense": {
"create": "Crea spesa",
"edit": "Modifica spesa",
"TitleField": {
"label": "Titolo spesa",
"placeholder": "Ristorante del lunedì sera",
"description": "Inserisci una descrizione per l'uscita."
},
"DateField": {
"label": "Data spesa",
"description": "Inserisci la data di quando è stata fatta la spesa"
},
"categoryFieldDescription": "Seleziona una categoria per la spesa.",
"paidByField": {
"label": "Pagato da",
"description": "Seleziona il partecipante che ha pagato la spesa."
},
"paidFor": {
"title": "Pagato per",
"description": "Seleziona per chi è stata pagato."
},
"splitModeDescription": "Seleziona come dividere la spesa.",
"attachDescription": "Vedi allegati spesa."
},
"amountField": {
"label": "Importo"
},
"isReimbursementField": {
"label": "Questo è un rimborso"
},
"categoryField": {
"label": "Categoria"
},
"notesField": {
"label": "Note"
},
"selectNone": "Seleziona nessuna",
"selectAll": "Seleziona tutto",
"shares": "condividi",
"advancedOptions": "Opzioni di divisione avanzate…",
"SplitModeField": {
"label": "Modalità split",
"evenly": "Uniforme",
"byShares": "Non uniforme Per quote",
"byPercentage": "Non uniforme Per percentuale",
"byAmount": "Non uniforme Per importo",
"saveAsDefault": "Salva come opzione di suddivisione predefinita"
},
"DeletePopup": {
"label": "Rimuovi",
"title": "Rimuovere questa spesa?",
"description": "Vuoi davvero eliminare questa spesa? Questa azione è irreversibile.",
"yes": "Si",
"cancel": "Annulla"
},
"attachDocuments": "Documenti allegati",
"create": "Crea",
"creating": "Sto creando…",
"save": "Salva",
"saving": "Sto salvando…",
"cancel": "Annulla",
"reimbursement": "Rimborso"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Il file è troppo grande",
"description": "La dimensione massima del file che puoi caricare è {maxSize}. Il tuo è ${size}."
},
"ErrorToast": {
"title": "Errore durante il caricamento del documento",
"description": "Si è verificato un errore durante il caricamento del documento. Riprova più tardi o seleziona un file diverso.",
"retry": "Riprova"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Crea spesa dalla ricevuta",
"title": "Crea dalla ricevuta",
"description": "Estrai le informazioni sulla spesa da una foto della ricevuta.",
"body": "Carica la foto di una ricevuta e, se possibile, la scannerizzeremo per estrarre le informazioni sulle spese.",
"selectImage": "Seleziona immagine…",
"titleLabel": "Titolo:",
"categoryLabel": "Categoria:",
"amountLabel": "Importo:",
"dateLabel": "Data:",
"editNext": "Successivamente potrai modificare le informazioni sulle spese.",
"continue": "Continua"
},
"unknown": "Unknown",
"TooBigToast": {
"title": "Il file è troppo grande",
"description": "La dimensione massima del file che puoi caricare è {maxSize}. Il tuo è ${size}."
},
"ErrorToast": {
"title": "Errore durante il caricamento del documento",
"description": "Si è verificato un errore durante il caricamento del documento. Riprova più tardi o seleziona un file diverso.",
"retry": "Riprova"
}
},
"Balances": {
"title": "Bilanci",
"description": "Questo è l'importo che ciascun partecipante ha pagato o deve pagare.",
"Reimbursements": {
"title": "Rimborsi suggeriti",
"description": "Ecco alcuni suggerimenti per ottimizzare i rimborsi tra i partecipanti.",
"noImbursements": "Sembra che il tuo gruppo non abbia bisogno di alcun rimborso 😁",
"owes": "<strong>{from}</strong> deve <strong>{to}</strong>",
"markAsPaid": "Segna come pagato"
}
},
"Stats": {
"title": "Statistiche",
"Totals": {
"title": "Totali",
"description": "Riepilogo delle spese dell'intero gruppo.",
"groupSpendings": "Spese totali del gruppo",
"groupEarnings": "Guadagno totale del gruppo",
"yourSpendings": "Le tue spese totali",
"yourEarnings": "I tuoi guadagni totali",
"yourShare": "La tua quota totale"
}
},
"Activity": {
"title": "Attività",
"description": "Panoramica di tutte le attività in questo gruppo.",
"noActivity": "Non c'è ancora alcuna attività nel tuo gruppo.",
"someone": "Qualcuno",
"settingsModified": "Le impostazioni del gruppo sono state modificate da <strong>{participant}</strong>.",
"expenseCreated": "Spesa <em>{expense}</em> creata da <strong>{participant}</strong>.",
"expenseUpdated": "Spesa <em>{expense}</em> aggiornata da <strong>{participant}</strong>.",
"expenseDeleted": "Spesa <em>{expense}</em> cancellata da <strong>{participant}</strong>.",
"Groups": {
"today": "Oggi",
"yesterday": "Ieri",
"earlierThisWeek": "All'inizio di questa settimana",
"lastWeek": "La settimana scorsa",
"earlierThisMonth": "All'inizio di questo mese",
"lastMonth": "Lo scorso mese",
"earlierThisYear": "All'inizio di questo anno",
"lastYear": "Lo scorso anno",
"older": "Più vecchio"
}
},
"Information": {
"title": "Informazioni",
"description": "Utilizza questo posto per aggiungere qualsiasi informazione che possa essere rilevante per i partecipanti al gruppo.",
"empty": "Ancora nessuna informazione sul gruppo."
},
"Settings": {
"title": "Impostazioni"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Condividi",
"description": "Per consentire agli altri partecipanti di vedere il gruppo e aggiungere spese, condividi il suo URL con loro.",
"warning": "Attenzione!",
"warningHelp": "Ogni persona con l'URL del gruppo potrà vedere e modificare le spese. Condividi con cautela!"
},
"SchemaErrors": {
"min1": "Inserisci almeno un carattere.",
"min2": "Inserisci almeno due caratteri.",
"max5": "Inserisci al massimo cinque caratteri.",
"max50": "Inserisci al massimo cinquanta caratteri.",
"duplicateParticipantName": "Un altro partecipante ha già questo nome.",
"titleRequired": "Inserisci un titolo.",
"invalidNumber": "Numero invalido.",
"amountRequired": "Devi inserire un importo",
"amountNotZero": "L'importo non deve essere zero.",
"amountTenMillion": "L'importo deve essere inferiore a 10.000.000.",
"paidByRequired": "È necessario selezionare un partecipante.",
"paidForMin1": "La spesa deve essere pagata per almeno un partecipante.",
"noZeroShares": "Tutti gli importi devono essere superiori a 0.",
"amountSum": "La somma degli importi deve essere uguale all'importo della spesa.",
"percentageSum": "La somma delle percentuali deve essere uguale a 100."
},
"Categories": {
"search": "Cerca categoria...",
"noCategory": "Nessuna categoria trovata.",
"Uncategorized": {
"heading": "Senza categoria",
"General": "Generale",
"Payment": "Pagamento"
},
"Entertainment": {
"heading": "Intrattenimento",
"Entertainment": "Intrattenimento",
"Games": "Games",
"Movies": "Film",
"Music": "Musica",
"Sports": "Sports"
},
"Food and Drink": {
"heading": "Cibo e Bevande",
"Food and Drink": "Cibo e Bevande",
"Dining Out": "Mangiare fuori",
"Groceries": "Generi alimentari",
"Liquor": "Liquori"
},
"Home": {
"heading": "Home",
"Home": "Home",
"Electronics": "Elettronica",
"Furniture": "Mobilia",
"Household Supplies": "Forniture per la casa",
"Maintenance": "Manutenzione",
"Mortgage": "Mutuo",
"Pets": "Animali",
"Rent": "Affitti",
"Services": "Servizi"
},
"Life": {
"heading": "Life",
"Childcare": "Assistenza all'infanzia",
"Clothing": "Vestiti",
"Education": "Educazione",
"Gifts": "Regali",
"Insurance": "Assicurazione",
"Medical Expenses": "Spese Mediche",
"Taxes": "Tasse"
},
"Transportation": {
"heading": "Trasporti",
"Transportation": "Trasporti",
"Bicycle": "Bicicletta",
"Bus/Train": "Bus/Treno",
"Car": "Auto",
"Gas/Fuel": "Gas/Carburante",
"Hotel": "Hotel",
"Parking": "Parcheggio",
"Plane": "Aereo",
"Taxi": "Taxi"
},
"Utilities": {
"heading": "Utilità",
"Utilities": "Utilità",
"Cleaning": "Pulizia",
"Electricity": "Elettricità",
"Heat/Gas": "Riscaldamento/Gas",
"Trash": "Spazzatura",
"TV/Phone/Internet": "TV/Telefono/Internet",
"Water": "Acqua"
}
}
}

400
messages/pl-PL.json Normal file
View File

@@ -0,0 +1,400 @@
{
"Homepage": {
"title": "Podziel <strong>Wydatki</strong> z <strong>Rodziną i Przyjaciółmi</strong>",
"description": "Witaj na twojej nowej instancji <strong>Spliita</strong> !",
"button": {
"groups": "Przejdź do grup",
"github": "GitHub"
}
},
"Header": {
"groups": "Grupy"
},
"Footer": {
"madeIn": "Stworzone Montréalu, Québec 🇨🇦",
"builtBy": "Napisane przez <author>Sebastien Castiela</author> i <source>kontrybutorów</source>"
},
"Expenses": {
"title": "Wydatki",
"description": "Tutaj są wydatki, które utworzyłeś dla twojej grupy.",
"create": "Dodaj wydatek",
"createFirst": "Stwórz swój pierwszy",
"noExpenses": "Twoja grupa nie ma jeszcze żadnych wydatków.",
"exportJson": "Eksportuj do JSONa",
"searchPlaceholder": "Szukaj wydatku...",
"ActiveUserModal": {
"title": "Kim jesteś?",
"description": "Podaj, którym uczestnikiem jesteś aby pozwolić nam określić jakie informacje mają być wyświetlane.",
"nobody": "Nie chcę wybierać nikogo",
"save": "Zapisz zmiany",
"footer": "To ustawienie może być potem zmienione w ustawieniach grupy."
},
"Groups": {
"upcoming": "Nadchodzące",
"thisWeek": "Ten tydzień",
"earlierThisMonth": "Wcześniej w tym miesiącu",
"lastMonth": "Ostatni miesiąc",
"earlierThisYear": "Wcześniej w tym roku",
"lastYera": "Poprzedni rok",
"older": "Starsze"
}
},
"ExpenseCard": {
"paidBy": "Opłacone przez <strong>{paidBy}</strong> dla <paidFor></paidFor>",
"receivedBy": "Otrzymane przez <strong>{paidBy}</strong> od <paidFor></paidFor>",
"yourBalance": "Twjoje saldo:"
},
"Groups": {
"myGroups": "Moje grupy",
"create": "Stwórz",
"loadingRecent": "Wczytywanie ostatnich grup...",
"NoRecent": {
"description": "Nie odwiedzałeś ostatnio żadnych grup.",
"create": "Stwórz",
"orAsk": "albo poproś przyjaciela, aby ci wysłał link do już istniejącej."
},
"recent": "Ostatnie grupy",
"starred": "Ogwiazdkowane grupy",
"archived": "Zarchiwizowane grupy",
"archive": "Zarchiwizuj grupę",
"unarchive": "Odarchwiruj grupę",
"removeRecent": "Usuń z ostatnich grup",
"RecentRemovedToast": {
"title": "Grupa została usunięta",
"description": "Grupa została usunięta z listy twoich ostatnich grup.",
"undoAlt": "Cofnij usunięcie grupy",
"undo": "Cofnij"
},
"AddByURL": {
"button": "Dodaj poprzez link URL",
"title": "Dodaj grupę poprzez link URL",
"description": "Jeśli grupa została ci udostępniona możesz wkleić jej link tutaj, aby dodać ją do twojej listy.",
"error": "Ups, nie możemy znaleźć grupy z podanego linka..."
},
"NotFound": {
"text": "Ta grupa nie istnieje.",
"link": "Idź do ostatnio odwiedzanych grup"
}
},
"GroupForm": {
"title": "Informacje o grupie",
"NameField": {
"label": "Nazwa grupy",
"placeholder": "Letni wyjazd",
"description": "Podaj nazwę dla twojej grupy."
},
"InformationField": {
"label": "Informacje o grupie",
"placeholder": "Jakie informacje mogą być ważne dla członków grupy?"
},
"CurrencyField": {
"label": "Symbol waluty",
"placeholder": "PLN, zł, $, €, £…",
"description": "Użyjemy go do wyświetlania ilości."
},
"Participants": {
"title": "Członkowie",
"description": "Podaj nazwę dla każdego członka.",
"protectedParticipant": "Ten członek wciąż bierze udział w rozliczeniach i nie może być usunięty.",
"new": "Nowy",
"add": "Dodaj członka",
"John": "Jan",
"Jane": "Joanna",
"Jack": "Jacek"
},
"Settings": {
"title": "Ustawienia lokalne",
"description": "Te ustawienia są ustawiane dla konkretnego urządzenia i służą do dostosowania twoich doświadczeń z aplikacją.",
"ActiveUserField": {
"label": "Aktywny użytkownik",
"placeholder": "Wybierz członka",
"none": "Brak",
"description": "Użytkownik używany domyślnie do wprowadzania wydatków."
},
"save": "Zapisz",
"saving": "Zapisywanie…",
"create": "Stwórz",
"creating": "Tworzenie…",
"cancel": "Anuluj"
}
},
"ExpenseForm": {
"Income": {
"create": "Dodaj wpływ",
"edit": "Edytuj wpływ",
"TitleField": {
"label": "Tytuł wpływu",
"placeholder": "Zwrot kaucji",
"description": "Podaj opis wpływu."
},
"DateField": {
"label": "Data wpływu",
"description": "Podaj datę otrzymania wpływu."
},
"categoryFieldDescription": "Wybierz typ wpływu.",
"paidByField": {
"label": "Otrzymane przez",
"description": "Wybierz członka, który otrzymał wpływ."
},
"paidFor": {
"title": "Otrzymany dla",
"description": "Podaj dla kogo wpływ był przeznaczony."
},
"splitModeDescription": "Wybierz jak podzielić wpływ.",
"attachDescription": "Zobacz i załącz rachunki do wpływu."
},
"Expense": {
"create": "Stwórz wydatek",
"edit": "Edytuj wydatek",
"TitleField": {
"label": "Tytuł wydatku",
"placeholder": "Poniedziałkowe wyjście do restauracji",
"description": "Podaj opis wydatku."
},
"DateField": {
"label": "Data wydatku",
"description": "Podaj datę opłacenia wydatku."
},
"categoryFieldDescription": "Podaj kategorię wydatku.",
"paidByField": {
"label": "Opłacone przez",
"description": "Wybierz członka, który zapłacił."
},
"paidFor": {
"title": "Opłacone dla",
"description": "Wybierz kogo dotyczył wydatek."
},
"splitModeDescription": "Wybierz jak podzielić wydatek.",
"attachDescription": "Zobacz i załącz rachunki do wydatku."
},
"amountField": {
"label": "Ilość"
},
"isReimbursementField": {
"label": "To jest zwrot kosztów"
},
"categoryField": {
"label": "Kategoria"
},
"notesField": {
"label": "Notatki"
},
"selectNone": "Nie wybieraj żadnego",
"selectAll": "Wybierz wszystkie",
"shares": "udział(y)",
"advancedOptions": "Zaawansowane opcje podziału...",
"SplitModeField": {
"label": "Typ podziału",
"evenly": "Równy",
"byShares": "Nierówny Poprzez udziały",
"byPercentage": "Nierówny Procentowo",
"byAmount": "Nierówny Na konkretne sumy",
"saveAsDefault": "Wybierz jako domyślny typ podziału"
},
"DeletePopup": {
"label": "Usuń",
"title": "Usunąć ten wydatek?",
"description": "Czy na pewno chcesz usunąć ten wydatek? Ta akcja jest nieodwracalna.",
"yes": "Tak",
"cancel": "Anuluj"
},
"attachDocuments": "Załącz dokumenty",
"create": "Stwórz",
"creating": "Tworzenie…",
"save": "Zapisz",
"saving": "Zapisywanie…",
"cancel": "Anuluj"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Ten plik jest zbyt duży",
"description": "Maksymalny rozmiar pliku to: {maxSize}. Twój plik ma: ${size}."
},
"ErrorToast": {
"title": "Błąd podczas wysyłania dokumentu",
"description": "Coś poszło nie tak podczas wysyłania dokumentu. Proszę spróbuj ponownie później, albo wybierz inny plik.",
"retry": "Ponów"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Utwórz wydatek z paragonu",
"title": "Utwórz z paragonu",
"description": "Wyodrębnianie informacji o wydatkach ze zdjęcia paragonu.",
"body": "Prześlij zdjęcie paragonu, a my zeskanujemy je, aby wyodrębnić informacje o wydatkach, jeśli to możliwe.",
"selectImage": "Wybierz obraz...",
"titleLabel": "Tytuł:",
"categoryLabel": "Kategoria:",
"amountLabel": "Ilość:",
"dateLabel": "Data:",
"editNext": "Następnie będziesz mógł edytować informacje o wydatkach.",
"continue": "Kontynuuj"
},
"unknown": "Nieznany",
"TooBigToast": {
"title": "Ten plik jest zbyt duży",
"description": "Maksymalny rozmiar pliku to: {maxSize}. Twój plik ma: ${size}."
},
"ErrorToast": {
"title": "Błąd podczas wysyłania dokumentu",
"description": "Coś poszło nie tak podczas wysyłania dokumentu. Proszę spróbuj ponownie później, albo wybierz inny plik.",
"retry": "Ponów"
}
},
"Balances": {
"title": "Salda",
"description": "Jest to kwota, którą każdy członek zapłacił lub za którą otrzymał zapłatę.",
"Reimbursements": {
"title": "Sugerowane zwroty",
"description": "Oto sugestie dotyczące optymalizacji zwrotów między uczestnikami.",
"noImbursements": "Wygląda na to, że w twojej grupie nie ma potrzeby żadnych zwrotów 😁",
"owes": "<strong>{from}</strong> jest winny dla <strong>{to}</strong>",
"markAsPaid": "Zaznacz jako opłacone"
}
},
"Stats": {
"title": "Statystyki",
"Totals": {
"title": "Podsumowanie",
"description": "Podsumowanie wydatków dla całej grupy.",
"groupSpendings": "Wydatki grupy",
"groupEarnings": "Wpływy grupy",
"yourSpendings": "Twoje wydatki",
"yourEarnings": "Twoje wpływy",
"yourShare": "Twoje udziały"
}
},
"Activity": {
"title": "Aktywność",
"description": "Przegląd wszystkich działań w tej grupie.",
"noActivity": "W grupie nie ma jeszcze żadnej aktywności.",
"someone": "Ktoś",
"settingsModified": "Ustawienia grupy zostały zmienione przez <strong>{participant}</strong>.",
"expenseCreated": "Wydatek <em>{expense}</em> stworzony przez <strong>{participant}</strong>.",
"expenseUpdated": "Wydatek <em>{expense}</em> zaktualizowany przez <strong>{participant}</strong>.",
"expenseDeleted": "Wydatek <em>{expense}</em> usunięty przez <strong>{participant}</strong>.",
"Groups": {
"today": "Dzisiaj",
"yesterday": "Wczoraj",
"earlierThisWeek": "Wcześniej w tym tygodniu",
"lastWeek": "W zeszłym tygodniu",
"earlierThisMonth": "Wcześniej w tym miesiącu",
"lastMonth": "Ostatni miesiąc",
"earlierThisYear": "Wcześniej w tym roku",
"lastYera": "Poprzedni rok",
"older": "Starsze"
}
},
"Information": {
"title": "Informacje",
"description": "Użyj tego miejsca, aby dodać wszelkie informacje, które mogą być istotne dla uczestników grupy..",
"empty": "Jeszcze nic tu nie ma."
},
"Settings": {
"title": "Ustawienia"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Udostępnij",
"description": "Aby inni uczestnicy mogli zobaczyć grupę i dodać wydatki, udostępnij im jej adres URL.",
"warning": "Uwaga!",
"warningHelp": "Każda osoba posiadająca adres URL grupy będzie mogła przeglądać i edytować wydatki. Udostępniaj ostrożnie!"
},
"SchemaErrors": {
"min1": "Wprowadź co najmniej jeden znak.",
"min2": "Wprowadź co najmniej dwa znaki.",
"max5": "Wprowadź maksymalnie pięć znaków.",
"max50": "Wprowadź maksymalnie 50 znaków.",
"duplicateParticipantName": "Ta nazwa jest już zajęta.",
"titleRequired": "Podaj tytuł.",
"invalidNumber": "Niewłaściwa liczba.",
"amountRequired": "Należy wprowadzić kwotę.",
"amountNotZero": "Kwota nie może być zerem.",
"amountTenMillion": "Kwota musi być niższa niż 10,000,000.",
"paidByRequired": "Musisz wybrać członka.",
"paidForMin1": "Wydatek musi zostać opłacony za co najmniej jednego uczestnika.",
"noZeroShares": "Wszystkie udziały muszą być większe niż 0.",
"amountSum": "Suma udziałów musi być równa wydatkowi.",
"percentageSum": "Suma procentów musi być równa 100."
},
"Categories": {
"search": "Szukaj kategorii...",
"noCategory": "Nie znaleziono kategorii.",
"Uncategorized": {
"heading": "Bez kategorii",
"General": "Ogólne",
"Payment": "Płatność"
},
"Entertainment": {
"heading": "Rozrywka",
"Entertainment": "Rozrywka",
"Games": "Gry",
"Movies": "Filmy",
"Music": "Muzyka",
"Sports": "Sporty"
},
"Food and Drink": {
"heading": "Jedzenie i Napoje",
"Food and Drink": "Jedzenie i Napoje",
"Dining Out": "Jedzenie na mieście",
"Groceries": "Zakupy",
"Liquor": "Alkohole"
},
"Home": {
"heading": "Dom",
"Home": "Dom",
"Electronics": "Elektronika",
"Furniture": "Meble",
"Household Supplies": "Artykuły gospodarstwa domowego",
"Maintenance": "Utrzymanie",
"Mortgage": "Czynsz",
"Pets": "Zwierzaki",
"Rent": "Czynsz",
"Services": "Usługi"
},
"Life": {
"heading": "Życie",
"Childcare": "Opieka nad dzieckiem",
"Clothing": "Ubrania",
"Education": "Edukacja",
"Gifts": "Prezenty",
"Insurance": "Ubezpieczenie",
"Medical Expenses": "Wydatki medyczne",
"Taxes": "Podatki"
},
"Transportation": {
"heading": "Transport",
"Transportation": "Transport",
"Bicycle": "Rower",
"Bus/Train": "Bus/Pociąg",
"Car": "Samochód",
"Gas/Fuel": "Paliwo",
"Hotel": "Hotel",
"Parking": "Parking",
"Plane": "Pociąg",
"Taxi": "Taxi"
},
"Utilities": {
"heading": "Media",
"Utilities": "Media",
"Cleaning": "Sprzątanie",
"Electricity": "Prąg",
"Heat/Gas": "Ogrzewanie",
"Trash": "Śmieci",
"TV/Phone/Internet": "TV/Telefon/Internet",
"Water": "Woda"
}
}
}

401
messages/ro.json Normal file
View File

@@ -0,0 +1,401 @@
{
"Homepage": {
"title": "Distribuie <strong>Cheltuielile</strong> cu <strong>Prietenii & Familia</strong>",
"description": "Bine ai venit pe noua ta instanță de <strong>Spliit</strong> !",
"button": {
"groups": "Mergi la grupuri",
"github": "GitHub"
}
},
"Header": {
"groups": "Grupuri"
},
"Footer": {
"madeIn": "Dezvoltat în Montréal, Québec 🇨🇦",
"builtBy": "Dezvoltat de către <author>Sebastien Castiel</author> și <source>contribuitori</source>"
},
"Expenses": {
"title": "Cheltuieli",
"description": "Aici sunt cheltuielile pe care le-ai creat pentru grupul tău.",
"create": "Adaugă o cheltuială",
"createFirst": "Adaug-o pe prima",
"noExpenses": "Grupul tău nu conține nicio cheltuială încă.",
"exportJson": "Salvează în JSON",
"searchPlaceholder": "Caută o cheltuială…",
"ActiveUserModal": {
"title": "Cum te numești?",
"description": "Spune-ne cine ești ca să putem îți afișăm informațiile relevante.",
"nobody": "Nu doresc să aleg pe nimeni",
"save": "Salvează",
"footer": "Această setare se poate schimba mai târziu din setările grupului."
},
"Groups": {
"upcoming": "Urmează",
"thisWeek": "În această săptămână",
"earlierThisMonth": "La începutul lunii",
"lastMonth": "Luna trecută",
"earlierThisYear": "La începutul anului",
"lastYera": "Anul trecut",
"older": "Mai vechi"
}
},
"ExpenseCard": {
"paidBy": "Plătit de <strong>{paidBy}</strong> pentru <paidFor></paidFor>",
"receivedBy": "Primit de <strong>{paidBy}</strong> pentru <paidFor></paidFor>",
"yourBalance": "Soldul tău:"
},
"Groups": {
"myGroups": "Grupurile mele",
"create": "Adaugă",
"loadingRecent": "Se încarcă ultimele tale grupuri…",
"NoRecent": {
"description": "Nu ai accesat niciun grup recent.",
"create": "Adaugă unul",
"orAsk": "sau roagă un prieten să îți trimită un link către unul deja existent."
},
"recent": "Ultimele grupuri",
"starred": "Grupuri favorite",
"archived": "Grupuri arhivate",
"archive": "Arhivează grupul",
"unarchive": "Dezarhivează grupul",
"removeRecent": "Șterge din ultimele grupuri",
"RecentRemovedToast": {
"title": "Grupul a fost șters.",
"description": "Grupul a fost șters din lista ta de grupuri recente.",
"undoAlt": "Anulează ștergerea grupului",
"undo": "Anulează"
},
"AddByURL": {
"button": "Adaugă folosind un URL",
"title": "Adaugă un grup folosind un URL",
"description": "Dacă un grup a fost distribuit cu tine, poți atașa URL-ul acestuia aici pentru a-l adăuga în listă.",
"error": "Ups, nu am găsit grupul folosind URL-ul primit de la tine…"
},
"NotFound": {
"text": "Acest grup nu există.",
"link": "Mergi la ultimele grupuri vizitate"
}
},
"GroupForm": {
"title": "Informații despre grup",
"NameField": {
"label": "Numele grupului",
"placeholder": "Vacanță de vară",
"description": "Adaugă un nume pentru grupul tău."
},
"InformationField": {
"label": "Informații despre grup",
"placeholder": "Ce informație este relevantă pentru membrii grupului?"
},
"CurrencyField": {
"label": "Monedă",
"placeholder": "$, €, £, RON …",
"description": "O vom folosi pentru a afișa sume."
},
"Participants": {
"title": "Membri",
"description": "Adaugă numele fiecărui membru.",
"protectedParticipant": "Acest membru a luat parte la cheltuieli și nu poate să fie șters.",
"new": "Nou",
"add": "Adaugă membru",
"John": "John",
"Jane": "Jane",
"Jack": "Jack"
},
"Settings": {
"title": "Setări locale",
"description": "Aceste setări sunt făcute pentru fiecare dispozitiv și sunt folosite pentru a-ți personaliza experiența.",
"ActiveUserField": {
"label": "Utilizator activ",
"placeholder": "Selectează un membru",
"none": "Niciunul",
"description": "Utilizatorul implicit pentru plata cheltuielilor."
},
"save": "Salvează",
"saving": "Se salvează…",
"create": "Adaugă",
"creating": "Se adaugă…",
"cancel": "Anulează"
}
},
"ExpenseForm": {
"Income": {
"create": "Adaugă un venit",
"edit": "Modifică venitul",
"TitleField": {
"label": "Titlul venitului",
"placeholder": "Cina de luni seară",
"description": "Adaugă o descriere pentru venit."
},
"DateField": {
"label": "Data venitului",
"description": "Adaugă data la care venitul a fost primit."
},
"categoryFieldDescription": "Selectează categoria venitului.",
"paidByField": {
"label": "Primit de către",
"description": "Selectează membrul care a primit venitul."
},
"paidFor": {
"title": "Primit pentru",
"description": "Selectează pentru cine a fost primit venitul."
},
"splitModeDescription": "Selectează cum să fie împărțit venitul.",
"attachDescription": "Vizualizează și atașează bonul pentru venit."
},
"Expense": {
"create": "Adaugă o cheltuială",
"edit": "Modifică cheltuiala",
"TitleField": {
"label": "Titlul cheltuielii",
"placeholder": "Cina de luni seară",
"description": "Adaugă o descriere pentru cheltuială."
},
"DateField": {
"label": "Data cheltuielii",
"description": "Adaugă data la care cheltuiala a fost facută."
},
"categoryFieldDescription": "Selectează categoria cheltuielii.",
"paidByField": {
"label": "Plătit de către",
"description": "Selectează membrul care a plătit cheltuiala."
},
"paidFor": {
"title": "Plătit pentru",
"description": "Selectează pentru cine a fost platită cheltuiala."
},
"splitModeDescription": "Selectează cum să fie împărțită cheltuiala.",
"attachDescription": "Vizualizează și atașează bonul pentru cheltuială."
},
"amountField": {
"label": "Sumă"
},
"isReimbursementField": {
"label": "Aceasta este o rambursare."
},
"categoryField": {
"label": "Categorie"
},
"notesField": {
"label": "Notițe"
},
"selectNone": "Nu selectez nimic",
"selectAll": "Selectez tot",
"shares": "distribuiri",
"advancedOptions": "Opțiuni avansate de împărțire…",
"SplitModeField": {
"label": "Împărțire",
"evenly": "Egal",
"byShares": "Inegal În funcție de parte",
"byPercentage": "Inegal În funcție de procentaj",
"byAmount": "Inegal În funcție de sumă",
"saveAsDefault": "Salvează ca și implicite opțiunile de împărțire"
},
"DeletePopup": {
"label": "Șterge",
"title": "Ștergi această cheltuială?",
"description": "Ești sigur că vrei să ștergi această cheltuială? Această acțiune este ireversibilă.",
"yes": "Da",
"cancel": "Anulează"
},
"attachDocuments": "Atașează documente",
"create": "Adaugă",
"creating": "Se adaugă…",
"save": "Salvează",
"saving": "Se salvează…",
"cancel": "Anulează",
"reimbursement": "Rambursare"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Fișierul este prea mare",
"description": "Dimensiunea maximă a fișierului pe care îl poți atașa este {maxSize}. Fișierul tău are ${size}."
},
"ErrorToast": {
"title": "Eroare la adăugarea documentului.",
"description": "Ceva a mers greșit la adăugarea fișierului. Încearcă mai târziu sau cum un alt fișier.",
"retry": "Reîncearcă"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Adaugă o cheltuială dintr-un bon",
"title": "Adaugă din bon",
"description": "Extrage informații despre cheltuială dintr-o poză cu bonul.",
"body": "Adaugă o poză cu bonul și vom încerca să o scanăm pentru a extrage informații despre cheltuială.",
"selectImage": "Selectează o imagine…",
"titleLabel": "Titlu:",
"categoryLabel": "Categorie:",
"amountLabel": "Sumă:",
"dateLabel": "Data:",
"editNext": "Vei putea sa modifici informațiile despre cheltuială în continuare.",
"continue": "Continuă"
},
"unknown": "Necunoscut",
"TooBigToast": {
"title": "Fișierul este prea mare",
"description": "Dimensiunea maximă a fișierului pe care il poți atașa este {maxSize}. Fișierul tău are ${size}."
},
"ErrorToast": {
"title": "Eroare la adăugarea documentului.",
"description": "Ceva a mers greșit la adăugarea fișierului. Încearcă mai târziu sau cum un alt fișier.",
"retry": "Reîncearcă"
}
},
"Balances": {
"title": "Solduri",
"description": "Aceasta este suma pe care fiecare membru a plătit-o sau cu care a fost plătit.",
"Reimbursements": {
"title": "Rambursări sugerate",
"description": "Acestea sunt sugestiile pentru rambursări optimizate între membrii.",
"noImbursements": "Se pare că grupul tău nu are nevoie de rambursări 😁",
"owes": "<strong>{from}</strong> datorează <strong>{to}</strong>",
"markAsPaid": "Bifează ca plătit"
}
},
"Stats": {
"title": "Statistici",
"Totals": {
"title": "Totaluri",
"description": "Sumarul cheltuielior pentru întregul grup.",
"groupSpendings": "Totalul cheltuielilor din grup",
"groupEarnings": "Totalul veniturilor din grup",
"yourSpendings": "Totalul cheltuielilor tale",
"yourEarnings": "Totalul veniturilor tale",
"yourShare": "Partea ta"
}
},
"Activity": {
"title": "Activități",
"description": "Rezumatul întregii activități a grupului.",
"noActivity": "Nu este nicio activitate în grupul tău încă.",
"someone": "Cineva",
"settingsModified": "Setările grupului au fost modificate de <strong>{participant}</strong>.",
"expenseCreated": "Cheltuială <em>{expense}</em> adăugată de <strong>{participant}</strong>.",
"expenseUpdated": "Cheltuială <em>{expense}</em> modificată de <strong>{participant}</strong>.",
"expenseDeleted": "Cheltuială <em>{expense}</em> ștearsă de <strong>{participant}</strong>.",
"Groups": {
"today": "Azi",
"yesterday": "Ieri",
"earlierThisWeek": "La începutul săptămânii",
"lastWeek": "Săptămâna trecută",
"earlierThisMonth": "La începutul lunii",
"lastMonth": "Luna trecuta",
"earlierThisYear": "La începutul anului",
"lastYear": "Anul trecut",
"older": "Mai vechi"
}
},
"Information": {
"title": "Informații",
"description": "Adaugă aici orice informație care poate să fie relevantă pentru membrii grupului.",
"empty": "Nicio informație de grup încă."
},
"Settings": {
"title": "Setări"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Distribuie",
"description": "Pentru ca ceilalți participanți să poată vedea grupul și cheltuielile adăugate, distribuie URL-ul acestuia cu ei.",
"warning": "Avertisment!",
"warningHelp": "Oricine are URL-ul grupului va putea să vadă și să editeze cheltuielile. Distribuie cu grijă!"
},
"SchemaErrors": {
"min1": "Introduceți cel puțin un caracter.",
"min2": "Introduceți cel puțin două caractere.",
"max5": "Introduceți cel mult cinci caractere.",
"max50": "Introduceți cel mult 50 de caractere.",
"duplicateParticipantName": "Un alt membru are deja acest nume.",
"titleRequired": "Vă rugăm să introduceți un titlu.",
"invalidNumber": "Număr invalid.",
"amountRequired": "Trebuie să introduceți o sumă.",
"amountNotZero": "Suma nu trebuie să fie zero.",
"amountTenMillion": "Suma trebuie să fie mai mică de 10,000,000.",
"paidByRequired": "Trebuie să selectați un membru.",
"paidForMin1": "Cheltuiala trebuie plătită pentru cel puțin un membru.",
"noZeroShares": "Toate părțile trebuie să fie mai mari de 0.",
"amountSum": "Suma valorilor trebuie să fie egală cu suma cheltuielilor.",
"percentageSum": "Suma procentajelor trebuie să fie egală cu 100."
},
"Categories": {
"search": "Căutați categorie…",
"noCategory": "Nicio categorie găsită.",
"Uncategorized": {
"heading": "Fără categorie",
"General": "General",
"Payment": "Plată"
},
"Entertainment": {
"heading": "Divertisment",
"Entertainment": "Divertisment",
"Games": "Jocuri",
"Movies": "Filme",
"Music": "Muzică",
"Sports": "Sporturi"
},
"Food and Drink": {
"heading": "Mâncare și Băutură",
"Food and Drink": "Mâncare și Băutură",
"Dining Out": "Cină în oraș",
"Groceries": "Alimente",
"Liquor": "Băuturi alcoolice"
},
"Home": {
"heading": "Acasă",
"Home": "Acasă",
"Electronics": "Electronice",
"Furniture": "Mobilier",
"Household Supplies": "Produse de uz casnic",
"Maintenance": "Întreținere",
"Mortgage": "Ipotecă",
"Pets": "Animale de companie",
"Rent": "Chirie",
"Services": "Servicii"
},
"Life": {
"heading": "Viață",
"Childcare": "Îngrijirea copiilor",
"Clothing": "Îmbrăcăminte",
"Education": "Educație",
"Gifts": "Cadouri",
"Insurance": "Asigurare",
"Medical Expenses": "Cheltuieli medicale",
"Taxes": "Impozite"
},
"Transportation": {
"heading": "Transport",
"Transportation": "Transport",
"Bicycle": "Bicicletă",
"Bus/Train": "Autobuz/Tren",
"Car": "Mașină",
"Gas/Fuel": "Gaz/Combustibil",
"Hotel": "Hotel",
"Parking": "Parcare",
"Plane": "Avion",
"Taxi": "Taxi"
},
"Utilities": {
"heading": "Utilități",
"Utilities": "Utilități",
"Cleaning": "Curățenie",
"Electricity": "Electricitate",
"Heat/Gas": "Încălzire/Gaz",
"Trash": "Gunoi",
"TV/Phone/Internet": "TV/Telefon/Internet",
"Water": "Apă"
}
}
}

401
messages/ru-RU.json Normal file
View File

@@ -0,0 +1,401 @@
{
"Homepage": {
"title": "Делитесь <strong>расходами</strong> с <strong>друзьями и семьей</strong>",
"description": "Добро пожаловать в вашу новую инстанцию <strong>Spliit</strong>!",
"button": {
"groups": "Перейти к группам",
"github": "GitHub"
}
},
"Header": {
"groups": "Группы"
},
"Footer": {
"madeIn": "Сделано в Монреале, Квебек 🇨🇦",
"builtBy": "Создано <author>Sebastien Castiel</author> и <source>соавторами</source>"
},
"Expenses": {
"title": "Расходы",
"description": "В этом разделе находятся расходы вашей группы.",
"create": "Создать расход",
"createFirst": "Создать первый расход",
"noExpenses": "У вашей группы пока что нет расходов.",
"exportJson": "Экспортировать в JSON",
"searchPlaceholder": "Поиск расходов…",
"ActiveUserModal": {
"title": "Кто вы?",
"description": "Скажите нам, кто вы из этого списка, чтобы мы могли подстроить интерфейс под вас.",
"nobody": "Не хочу выбирать",
"save": "Сохранить изменения",
"footer": "Вы сможете изменить это в настройках группы."
},
"Groups": {
"upcoming": "Будущее",
"thisWeek": "На этой неделе",
"earlierThisMonth": "Ранее в этом месяце",
"lastMonth": "В прошлом месяце",
"earlierThisYear": "Ранее в этом году",
"lastYera": "В прошлом году",
"older": "Очень давно"
}
},
"ExpenseCard": {
"paidBy": "Потратил <strong>{paidBy}</strong> за <paidFor></paidFor>",
"receivedBy": "Получил <strong>{paidBy}</strong> за <paidFor></paidFor>",
"yourBalance": "Изменение баланса участника:"
},
"Groups": {
"myGroups": "Мои группы",
"create": "Создать",
"loadingRecent": "Загрузка недавних групп…",
"NoRecent": {
"description": "У вас нет недавних групп.",
"create": "Вы можете создать группу",
"orAsk": "или попросить вашего друга отправить вам ссылку на существующую."
},
"recent": "Недавние группы",
"starred": "Избранные",
"archived": "Архивированные группы",
"archive": "Архивировать группу",
"unarchive": "Восстановить группу",
"removeRecent": "Убрать группу из недавних",
"RecentRemovedToast": {
"title": "Группа убрана",
"description": "Группа была убрана из вашего списка недавних групп.",
"undoAlt": "Отменить удаление группы из этого списка",
"undo": "Отмена"
},
"AddByURL": {
"button": "Добавить по URL",
"title": "Добавить группу по URL",
"description": "Если с вами поделились ссылкой на группу, вставьте ее сюда, чтобы добавить ее в ваш список.",
"error": "К сожалению, мы не смогли найти группу по этому URL."
},
"NotFound": {
"text": "Этой группы не существует",
"link": "Перейти к списку недавних групп"
}
},
"GroupForm": {
"title": "Сведения о группе",
"NameField": {
"label": "Название группы",
"placeholder": "Летние поездки",
"description": "Введите название вашей группы."
},
"InformationField": {
"label": "Информация о группе",
"placeholder": "Что важно знать участникам этой группы?"
},
"CurrencyField": {
"label": "Символ валюты",
"placeholder": "$, €, £…",
"description": "Этот символ будет использован для отображений денежных сумм."
},
"Participants": {
"title": "Участники",
"description": "Введите имя каждого участника.",
"protectedParticipant": "Этот участник — часть расходов, и поэтому не может быть удален.",
"new": "Новый участник",
"add": "Добавить участника",
"John": "Александр",
"Jane": "Михаил",
"Jack": "Иван"
},
"Settings": {
"title": "Локальные настройки",
"description": "Эти настройки хранятся на вашем устройстве и используются для подстройки интерфейса для вас.",
"ActiveUserField": {
"label": "Активный участник",
"placeholder": "Выберите участника",
"none": "Не выбран",
"description": "Этот участник будет автоматически выбран при создании нового расхода."
},
"save": "Сохранить",
"saving": "Сохранение…",
"create": "Создать",
"creating": "Создание…",
"cancel": "Отмена"
}
},
"ExpenseForm": {
"Income": {
"create": "Создать доход",
"edit": "Изменить доход",
"TitleField": {
"label": "Название доходв",
"placeholder": "Поход в ресторан",
"description": "Введите описание для этого дохода."
},
"DateField": {
"label": "Дата дохода",
"description": "Введите дату, когда этот доход был получен."
},
"categoryFieldDescription": "Выберите категорию дохода.",
"paidByField": {
"label": "Получивший",
"description": "Выберите участника, который получил этот доход."
},
"paidFor": {
"title": "Участники",
"description": "Выберите тех, между кем этот доход будет распределен."
},
"splitModeDescription": "Выберите, как доход необходимо распределить между людьми.",
"attachDescription": "Просмотр и прикрепление чеков к этому расходу."
},
"Expense": {
"create": "Создать расход",
"edit": "Изменить расход",
"TitleField": {
"label": "Название расхода",
"placeholder": "Поход в ресторан",
"description": "Введите описание для этого расхода."
},
"DateField": {
"label": "Дата расхода",
"description": "Введите дату, когда этот расход был совершен."
},
"categoryFieldDescription": "Выберите категорию расхода.",
"paidByField": {
"label": "Оплативший",
"description": "Выберите участника, который оплатил этот расход."
},
"paidFor": {
"title": "Участники",
"description": "Выберите тех, между кем этот расход будет распределен. Если этот расход — возмещение участнику (участникам), выберите только его (их)."
},
"splitModeDescription": "Выберите, как расход необходимо распределить между людьми.",
"attachDescription": "Просмотр и прикрепление чеков к этому доходу."
},
"amountField": {
"label": "Сумма"
},
"isReimbursementField": {
"label": "Этот расход является возмещением"
},
"categoryField": {
"label": "Категория"
},
"notesField": {
"label": "Заметки"
},
"selectNone": "Выбрать никого",
"selectAll": "Выбрать всех",
"shares": "доля(и)",
"advancedOptions": "Дополнительные настройки распределения…",
"SplitModeField": {
"label": "Режим разделения",
"evenly": "Равный",
"byShares": "Неравный По долям",
"byPercentage": "Неравный По процентам",
"byAmount": "Неравный По суммам",
"saveAsDefault": "Сделать режимом по умолчанию"
},
"DeletePopup": {
"label": "Удалить",
"title": "Удалить этот расход?",
"description": "Вы действительно хотите удалить этот расход? Это действие нельзя отменить.",
"yes": "Удалить",
"cancel": "Отмена"
},
"attachDocuments": "Прикрепить документы",
"create": "Создать",
"creating": "Создание…",
"save": "Сохранить",
"saving": "Сохранение…",
"cancel": "Отмена",
"reimbursement": "Возмещение"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Файл слишком большой",
"description": "Максимальный размер файла, который можно загрузить — {maxSize}. Размер вашего файла — ${size}."
},
"ErrorToast": {
"title": "Ошибка при загрузке документа",
"description": "При загрузке документа что-то пошло не так. Пожалуйста, повторите позднее или попробуйте загрузить другой файл.",
"retry": "Повторить"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Создать расход из чека",
"title": "Создать из чека",
"description": "Извлечение информации о расходах из фотографии чека",
"body": "Загрузите фотографию чека, и мы попытаемся отсканировать его, чтобы извлечь информацию о расходах.",
"selectImage": "Выбрать изображение…",
"titleLabel": "Название:",
"categoryLabel": "Категория:",
"amountLabel": "Сумма:",
"dateLabel": "Дата:",
"editNext": "Вы сможете изменить эту информацию позднее.",
"continue": "Продолжить"
},
"unknown": "Неизвестно",
"TooBigToast": {
"title": "Файл слишком большой",
"description": "Максимальный размер файла, который можно загрузить — {maxSize}. Размер вашего файла — ${size}."
},
"ErrorToast": {
"title": "Ошибка при загрузке документа",
"description": "При загрузке документа что-то пошло не так. Пожалуйста, повторите позднее или попробуйте загрузить другой файл.",
"retry": "Повторить"
}
},
"Balances": {
"title": "Балансы",
"description": "Это список балансов всех участников группы. Баланс увеличивается у тех, кто оплачивает расход, и уменьшается у тех, между кем он был распределен.",
"Reimbursements": {
"title": "Предложенные возмещения",
"description": "Вот список задолженностей между участниками.",
"noImbursements": "Похоже, все в расчете 😁",
"owes": "<strong>{from}</strong> должен <strong>{to}</strong>",
"markAsPaid": "Пометить оплаченным"
}
},
"Stats": {
"title": "Статистика",
"Totals": {
"title": "Итоговые суммы",
"description": "Общая информация о расходах вашей группы.",
"groupSpendings": "Всего потрачено группой",
"groupEarnings": "Всего заработано группой",
"yourSpendings": "Всего потрачено вами",
"yourEarnings": "Всего заработано вами",
"yourShare": "Ваша суммарная доля"
}
},
"Activity": {
"title": "Активность",
"description": "Обзор действий, совершенных участниками этой группы.",
"noActivity": "История действий пуста.",
"someone": "Аноним",
"settingsModified": "Настройки группы изменены участником <strong>{participant}</strong>.",
"expenseCreated": "Расход <em>{expense}</em> создан участником <strong>{participant}</strong>.",
"expenseUpdated": "Расход <em>{expense}</em> изменен участником <strong>{participant}</strong>.",
"expenseDeleted": "Расход <em>{expense}</em> удален участником <strong>{participant}</strong>.",
"Groups": {
"today": "Сегодня",
"yesterday": "Вчера",
"earlierThisWeek": "Ранее на этой неделе",
"lastWeek": "На прошлой неделе",
"earlierThisMonth": "Ранее в этом месяце",
"lastMonth": "В прошлом месяце",
"earlierThisYear": "Ранее в этом году",
"lastYear": "В прошлом году",
"older": "Очень давно"
}
},
"Information": {
"title": "Информация",
"description": "В этом разделе вы можете добавить важную для участников информацию.",
"empty": "Информации нет."
},
"Settings": {
"title": "Настройки"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Поделиться",
"description": "Чтобы другие участники получили доступ к этой группе и смогли добавлять расходы, отправьте им этот URL.",
"warning": "Внимание!",
"warningHelp": "Любой человек с доступом к этой ссылке сможет просматривать и редактировать расходы. Будьте осторожны!"
},
"SchemaErrors": {
"min1": "Введите как минимум один символ.",
"min2": "Введите как минимум два символа.",
"max5": "Введите максимум 5 символов.",
"max50": "Введите максимум 50 символов.",
"duplicateParticipantName": "Участник с таким именем уже существует.",
"titleRequired": "Пожалуйста, введите название.",
"invalidNumber": "Неверное число.",
"amountRequired": "Пожалуйста, введите сумму.",
"amountNotZero": "Сумма не может быть нулевой.",
"amountTenMillion": "Сумма должна быть меньше 10 000 000.",
"paidByRequired": "Пожалуйста, выберите участника.",
"paidForMin1": "За этот расход должен заплатить как минимум один участник.",
"noZeroShares": "Все доли должны быть больше 0.",
"amountSum": "Сумма расхода должна быть равна сумме значений, распределенных между участниками.",
"percentageSum": "Сумма процентов должна быть равна 100."
},
"Categories": {
"search": "Поиск категорий...",
"noCategory": "Категорий не нашлось.",
"Uncategorized": {
"heading": "Без категории",
"General": "Общее",
"Payment": "Выплата"
},
"Entertainment": {
"heading": "Развлечения",
"Entertainment": "Развлечения",
"Games": "Игры",
"Movies": "Кино",
"Music": "Музыка",
"Sports": "Спорт"
},
"Food and Drink": {
"heading": "Еда и напитки",
"Food and Drink": "Еда и напитки",
"Dining Out": "Рестораны и кафе",
"Groceries": "Продукты",
"Liquor": "Напитки"
},
"Home": {
"heading": "Дом",
"Home": "Дом",
"Electronics": "Электроника",
"Furniture": "Мебель",
"Household Supplies": "Расходные материалы",
"Maintenance": "Уборка",
"Mortgage": "Ипотека",
"Pets": "Домашние животные",
"Rent": "Аренда",
"Services": "Коммунальные расходы"
},
"Life": {
"heading": "Жизнь",
"Childcare": "Дети",
"Clothing": "Одежда",
"Education": "Образование",
"Gifts": "Подарки",
"Insurance": "Страховки",
"Medical Expenses": "Медицина",
"Taxes": "Налоги"
},
"Transportation": {
"heading": "Транспорт",
"Transportation": "Транспорт",
"Bicycle": "Велосипед",
"Bus/Train": "Автобусы и поезда",
"Car": "Авто",
"Gas/Fuel": "Топливо",
"Hotel": "Отели",
"Parking": "Парковка",
"Plane": "Самолеты",
"Taxi": "Такси"
},
"Utilities": {
"heading": "Коммунальные расходы",
"Utilities": "Коммунальные расходы",
"Cleaning": "Клининг",
"Electricity": "Электричество",
"Heat/Gas": "Отопление/Газ",
"Trash": "Утилизация отходов",
"TV/Phone/Internet": "ТВ/Телефон/Интернет",
"Water": "Вода"
}
}
}

401
messages/ua-UA.json Normal file
View File

@@ -0,0 +1,401 @@
{
"Homepage": {
"title": "Ділися <strong>витратами</strong> з <strong>друзями та родиною</strong>",
"description": "Ласкаво просимо у ваш новий <strong>Spliit</strong>!",
"button": {
"groups": "Перейти до груп",
"github": "GitHub"
}
},
"Header": {
"groups": "Групи"
},
"Footer": {
"madeIn": "Зроблено в Монреалі, Квебек 🇨🇦",
"builtBy": "Створено <author>Себастіаном Кастіелем</author> та <source>учасниками</source>"
},
"Expenses": {
"title": "Витрати",
"description": "Тут знаходяться витрати вашої групи",
"create": "Створити витрату",
"createFirst": "Створіть першу витрату",
"noExpenses": "У вашій групі ще немає витрат",
"exportJson": "Експортувати у JSON",
"searchPlaceholder": "Пошук витрат...",
"ActiveUserModal": {
"title": "Хто ви?",
"description": "Скажіть нам, хто ви серед учасників, щоб ми могли налаштувати відображення інформації під вас",
"nobody": "Я не хочу нікого обирати",
"save": "Зберегти зміни",
"footer": "Це налаштування можна змінити пізніше в налаштуваннях групи"
},
"Groups": {
"upcoming": "Майбутні",
"thisWeek": "Цього тижня",
"earlierThisMonth": "Раніше цього місяця",
"lastMonth": "Минулого місяця",
"earlierThisYear": "Раніше цього року",
"lastYera": "Минулого року",
"older": "Старіші"
}
},
"ExpenseCard": {
"paidBy": "Сплачено <strong>{paidBy}</strong> за <paidFor></paidFor>",
"receivedBy": "Отримано <strong>{paidBy}</strong> за <paidFor></paidFor>",
"yourBalance": "Ваш баланс:"
},
"Groups": {
"myGroups": "Мої групи",
"create": "Створити",
"loadingRecent": "Завантаження нещодавніх груп...",
"NoRecent": {
"description": "Ви не відвідували жодних груп останнім часом",
"create": "Створіть групу",
"orAsk": "або попросіть друга надіслати вам посилання на існуючу"
},
"recent": "Нещодавні групи",
"starred": "Обрані групи",
"archived": "Архівовані групи",
"archive": "Архівувати групу",
"unarchive": "Розархівувати групу",
"removeRecent": "Видалити з останніх груп",
"RecentRemovedToast": {
"title": "Група була видалена",
"description": "Група видалена зі списку ваших нещодавніх груп",
"undoAlt": "Скасувати видалення групи",
"undo": "Скасувати"
},
"AddByURL": {
"button": "Додати за URL",
"title": "Додати групу за URL",
"description": "Якщо з вами поділились групою, ви можете вставити її URL тут, щоб додати до свого списку",
"error": "На жаль, ми не змогли знайти групу за наданим URL"
},
"NotFound": {
"text": "Цієї групи не існує",
"link": "Перейти до нещодавно відвіданих груп"
}
},
"GroupForm": {
"title": "Інформація про групу",
"NameField": {
"label": "Назва групи",
"placeholder": "Літні канікули",
"description": "Введіть назву для вашої групи"
},
"InformationField": {
"label": "Інформація про групу",
"placeholder": "Яка інформація важлива для учасників групи?"
},
"CurrencyField": {
"label": "Символ валюти",
"placeholder": "₴, $, €, £..",
"description": "Ми будемо використовувати його для відображення сум"
},
"Participants": {
"title": "Учасники",
"description": "Введіть ім'я кожного учасника",
"protectedParticipant": "Цей учасник бере участь у витратах і не може бути видалений",
"new": "Новий",
"add": "Додати учасника",
"John": "Андрій",
"Jane": "Оксана",
"Jack": "Василь"
},
"Settings": {
"title": "Локальні налаштування",
"description": "Ці налаштування встановлюються на кожному пристрої окремо і використовуються для налаштування інтерфейсу під вас",
"ActiveUserField": {
"label": "Активний користувач",
"placeholder": "Обрати учасника",
"none": "Ніхто",
"description": "Користувач використовується за замовчуванням для оплати витрат"
},
"save": "Зберегти",
"saving": "Збереження...",
"create": "Створити",
"creating": "Створення...",
"cancel": "Скасувати"
}
},
"ExpenseForm": {
"Income": {
"create": "Створити дохід",
"edit": "Редагувати дохід",
"TitleField": {
"label": "Назва доходу",
"placeholder": "Ресторан в понеділок ввечері",
"description": "Введіть опис для доходу"
},
"DateField": {
"label": "Дата доходу",
"description": "Введіть дату, коли було отримано дохід"
},
"categoryFieldDescription": "Оберіть категорію доходу",
"paidByField": {
"label": "Отримав",
"description": "Оберіть учасника, який отримав дохід"
},
"paidFor": {
"title": "Учасники",
"description": "Виберіть тих, між ким цей дохід буде розподілено"
},
"splitModeDescription": "Оберіть, як розділити дохід між учасниками",
"attachDescription": "Перегляньте та прикріпіть чеки до доходу"
},
"Expense": {
"create": "Створити витрату",
"edit": "Редагувати витрату",
"TitleField": {
"label": "Назва витрати",
"placeholder": "Ресторан в понеділок ввечері",
"description": "Введіть опис для витрати"
},
"DateField": {
"label": "Дата витрати",
"description": "Введіть дату, коли було сплачено"
},
"categoryFieldDescription": "Оберіть категорію витрати",
"paidByField": {
"label": "Сплатив",
"description": "Оберіть учасника, який сплатив"
},
"paidFor": {
"title": "Учасники",
"description": "Оберіть тих, між ким цю витрату буде розподілено. Якщо ця витрата - відшкодування учаснику (учасникам), виберіть тільки його (їх)."
},
"splitModeDescription": "Оберіть, як розділити витрату",
"attachDescription": "Перегляньте та прикріпіть чеки до витрати"
},
"amountField": {
"label": "Сума"
},
"isReimbursementField": {
"label": "Це відшкодування"
},
"categoryField": {
"label": "Категорія"
},
"notesField": {
"label": "Примітки"
},
"selectNone": "Обрати жодного",
"selectAll": "Обрати всіх",
"shares": "частка(и)",
"advancedOptions": "Розширені опції поділу..",
"SplitModeField": {
"label": "Режим поділу",
"evenly": "Рівномірно",
"byShares": "Нерівномірно за частками",
"byPercentage": "Нерівномірно за відсотками",
"byAmount": "Нерівномірно за сумами",
"saveAsDefault": "Зберегти як параметри поділу за замовчуванням"
},
"DeletePopup": {
"label": "Видалити",
"title": "Видалити цю витрату?",
"description": "Ви дійсно хочете видалити цю витрату? Ця дія не може бути скасована",
"yes": "Так",
"cancel": "Скасувати"
},
"attachDocuments": "Прикріпити документи",
"create": "Створити",
"creating": "Створення..",
"save": "Зберегти",
"saving": "Збереження..",
"cancel": "Скасувати",
"reimbursement": "Відшкодування"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "Файл занадто великий",
"description": "Максимальний розмір файлу, який можна завантажити, становить {maxSize}. Ваш файл {size}"
},
"ErrorToast": {
"title": "Помилка під час завантаження документа",
"description": "Виникла помилка під час завантаження документа. Будь ласка, спробуйте ще раз пізніше або виберіть інший файл",
"retry": "Спробувати ще раз"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "Створити витрату з чека",
"title": "Створити з чека",
"description": "Отримайте інформацію про витрати з фото чека",
"body": "Завантажте фото чека, і ми спробуємо витягнути інформацію про витрати, якщо це можливо",
"selectImage": "Вибрати зображення..",
"titleLabel": "Назва:",
"categoryLabel": "Категорія:",
"amountLabel": "Сума:",
"dateLabel": "Дата:",
"editNext": "Ви зможете відредагувати інформацію про витрати пізніше",
"continue": "Продовжити"
},
"unknown": "Невідомо",
"TooBigToast": {
"title": "Файл занадто великий",
"description": "Максимальний розмір файлу, який можна завантажити, становить {maxSize}. Ваш файл {size}"
},
"ErrorToast": {
"title": "Помилка під час завантаження документа",
"description": "Виникла помилка під час завантаження документа. Будь ласка, спробуйте ще раз пізніше або виберіть інший файл",
"retry": "Спробувати ще раз"
}
},
"Balances": {
"title": "Баланси",
"description": "Це список балансів всіх учасників групи. Баланс збільшується у тих, хто слачує витрату, і зменшується в тих, між ким вона була розподілена",
"Reimbursements": {
"title": "Запропоновані відшкодування",
"description": "Ось пропозиції для оптимізованих відшкодувань між учасниками",
"noImbursements": "Схоже, ніхто нікому не винен 😁",
"owes": "<strong>{from}</strong> винен <strong>{to}</strong>",
"markAsPaid": "Позначити як сплачене"
}
},
"Stats": {
"title": "Статистика",
"Totals": {
"title": "Загальні дані",
"description": "Загальний огляд витрат групи",
"groupSpendings": "Загальні витрати групи",
"groupEarnings": "Загальні доходи групи",
"yourSpendings": "Ваші загальні витрати",
"yourEarnings": "Ваші загальні доходи",
"yourShare": "Ваша частка"
}
},
"Activity": {
"title": "Активність",
"description": "Огляд усієї активності в цій групі",
"noActivity": "У вашій групі ще немає активності",
"someone": "Хтось",
"settingsModified": "Налаштування групи змінені <strong>{participant}</strong>",
"expenseCreated": "Витрата <em>{expense}</em> створена <strong>{participant}</strong>",
"expenseUpdated": "Витрата <em>{expense}</em> оновлена <strong>{participant}</strong>",
"expenseDeleted": "Витрата <em>{expense}</em> видалена <strong>{participant}</strong>",
"Groups": {
"today": "Сьогодні",
"yesterday": "Вчора",
"earlierThisWeek": "Раніше цього тижня",
"lastWeek": "Минулого тижня",
"earlierThisMonth": "Раніше цього місяця",
"lastMonth": "Минулого місяця",
"earlierThisYear": "Раніше цього року",
"lastYear": "Минулого року",
"older": "Старіші"
}
},
"Information": {
"title": "Інформація",
"description": "Використовуйте це місце, щоб додати будь-яку інформацію, яка може бути корисною для учасників групи",
"empty": "Ще немає інформації про групу"
},
"Settings": {
"title": "Налаштування"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "Поділитися",
"description": "Щоб інші учасники могли побачити групу і додати витрати, поділіться з ними її URL",
"warning": "Попередження!",
"warningHelp": "Кожна людина з URL групи зможе переглядати та редагувати витрати. Діліться з обережністю!"
},
"SchemaErrors": {
"min1": "Введіть принаймні один символ",
"min2": "Введіть принаймні два символи",
"max5": "Введіть не більше п'яти символів",
"max50": "Введіть не більше 50 символів",
"duplicateParticipantName": "Інший учасник уже має це ім'я",
"titleRequired": "Будь ласка, введіть назву",
"invalidNumber": "Невірний номер",
"amountRequired": "Необхідно ввести суму",
"amountNotZero": "Сума не повинна дорівнювати нулю",
"amountTenMillion": "Сума повинна бути меншою за 10,000,000",
"paidByRequired": "Ви повинні обрати учасника",
"paidForMin1": "Витрата повинна бути сплачена принаймні для одного учасника",
"noZeroShares": "Усі частки повинні бути більшими за 0",
"amountSum": "Сума повинна відповідати витраті",
"percentageSum": "Сума відсотків повинна дорівнювати 100"
},
"Categories": {
"search": "Шукати категорію..",
"noCategory": "Категорії не знайдено",
"Uncategorized": {
"heading": "Без категорії",
"General": "Загальне",
"Payment": "Оплата"
},
"Entertainment": {
"heading": "Розваги",
"Entertainment": "Розваги",
"Games": "Ігри",
"Movies": "Фільми",
"Music": "Музика",
"Sports": "Спорт"
},
"Food and Drink": {
"heading": "Їжа та напої",
"Food and Drink": "Їжа та напої",
"Dining Out": "Ресторани",
"Groceries": "Продукти",
"Liquor": "Алкоголь"
},
"Home": {
"heading": "Дім",
"Home": "Дім",
"Electronics": "Електроніка",
"Furniture": "Меблі",
"Household Supplies": "Домашні потреби",
"Maintenance": "Обслуговування",
"Mortgage": "Іпотека",
"Pets": "Домашні тварини",
"Rent": "Оренда",
"Services": "Послуги"
},
"Life": {
"heading": "Життя",
"Childcare": "Догляд за дітьми",
"Clothing": "Одяг",
"Education": "Освіта",
"Gifts": "Подарунки",
"Insurance": "Страхування",
"Medical Expenses": "Медичні витрати",
"Taxes": "Податки"
},
"Transportation": {
"heading": "Транспорт",
"Transportation": "Транспорт",
"Bicycle": "Велосипед",
"Bus/Train": "Автобус/Поїзд",
"Car": "Автомобіль",
"Gas/Fuel": "Паливо",
"Hotel": "Готель",
"Parking": "Паркінг",
"Plane": "Літак",
"Taxi": "Таксі"
},
"Utilities": {
"heading": "Комунальні послуги",
"Utilities": "Комунальні послуги",
"Cleaning": "Прибирання",
"Electricity": "Електроенергія",
"Heat/Gas": "Опалення/Газ",
"Trash": "Сміття",
"TV/Phone/Internet": "ТБ/Телефон/Інтернет",
"Water": "Вода"
}
}
}

401
messages/zh-CN.json Normal file
View File

@@ -0,0 +1,401 @@
{
"Homepage": {
"title": "与<strong>朋友和家人</strong>共享<strong>开支</strong>",
"description": "欢迎使用你的全新<strong>Spliit</strong>实例!",
"button": {
"groups": "前往群组",
"github": "GitHub"
}
},
"Header": {
"groups": "群组"
},
"Footer": {
"madeIn": "Made in Montréal, Québec 🇨🇦",
"builtBy": "由 <author>Sebastien Castiel</author> 以及 <source>社区贡献者们</source> 共同构建"
},
"Expenses": {
"title": "消费",
"description": "这里有你为你的群组创建的消费。",
"create": "创建消费",
"createFirst": "创建首个消费",
"noExpenses": "你的群组内目前没有任何消费。",
"exportJson": "导出到JSON",
"searchPlaceholder": "查找消费……",
"ActiveUserModal": {
"title": "你是哪位?",
"description": "告诉我们你在群组中的身份,以便定制你的信息呈现方式。",
"nobody": "我不想选择任何人",
"save": "保存变更",
"footer": "此项设定之后可以在群组设定中修改。"
},
"Groups": {
"upcoming": "即将到来",
"thisWeek": "本周",
"earlierThisMonth": "本月早些时候",
"lastMonth": "上个月",
"earlierThisYear": "本年早些时候",
"lastYera": "去年",
"older": "更早"
}
},
"ExpenseCard": {
"paidBy": "<strong>{paidBy}</strong> 为 <paidFor></paidFor> 支付。",
"receivedBy": "<strong>{paidBy}</strong> 为 <paidFor></paidFor> 接收。",
"yourBalance": "你的余额:"
},
"Groups": {
"myGroups": "我的群组",
"create": "创建",
"loadingRecent": "加载最近的群组……",
"NoRecent": {
"description": "你最近没有访问任何群组。",
"create": "创建一个群组",
"orAsk": "或者让你的朋友发给你现有群组的链接。"
},
"recent": "最近的群组",
"starred": "已收藏的群组",
"archived": "已归档的群组",
"archive": "归档群组",
"unarchive": "取消归档群组",
"removeRecent": "从最近的群组中删除",
"RecentRemovedToast": {
"title": "群组已删除",
"description": "群组已从你的最近群组列表中删除。",
"undoAlt": "撤销群组删除",
"undo": "撤销"
},
"AddByURL": {
"button": "使用URL添加",
"title": "使用URL添加一个群组",
"description": "如果你被分享了一个群组你可以将群组的URL粘贴到这里以添加这个群组。",
"error": "哎呀我们没办法根据你的URL找到相应的群组……"
},
"NotFound": {
"text": "该群组不存在。",
"link": "跳转到最近访问过的群组"
}
},
"GroupForm": {
"title": "群组信息",
"NameField": {
"label": "群组名",
"placeholder": "暑假",
"description": "为你的群组输入一个名字。"
},
"InformationField": {
"label": "群组信息",
"placeholder": "哪些信息与群组成员有关?"
},
"CurrencyField": {
"label": "货币标志",
"placeholder": "$, €, £…",
"description": "我们根据这个显示相应的货币金额。"
},
"Participants": {
"title": "群组成员",
"description": "输入每位成员的名字。",
"protectedParticipant": "群组成员是消费的一部分,不可删除。",
"new": "新建",
"add": "添加群组成员",
"John": "John",
"Jane": "Jane",
"Jack": "Jack"
},
"Settings": {
"title": "本地设定",
"description": "这些设定是按设备设定的,用于定制你的使用体验。",
"ActiveUserField": {
"label": "当前用户",
"placeholder": "选择一个群组成员",
"none": "无",
"description": "用于支付消费的默认用户。"
},
"save": "保存",
"saving": "保存中……",
"create": "创建",
"creating": "创建中",
"cancel": "取消"
}
},
"ExpenseForm": {
"Income": {
"create": "创建收入",
"edit": "编辑收入",
"TitleField": {
"label": "收入标题",
"placeholder": "周一晚上的餐厅",
"description": "描述这个收入。"
},
"DateField": {
"label": "收入日期",
"description": "输入收到这笔收入的日期。"
},
"categoryFieldDescription": "选择收入类别。",
"paidByField": {
"label": "接收到",
"description": "选择接收到这笔收入的群组成员。"
},
"paidFor": {
"title": "接收给",
"description": "选择收入是为谁而收。"
},
"splitModeDescription": "选择如何划分这笔收入。",
"attachDescription": "查看并为这笔收入附加收据。"
},
"Expense": {
"create": "创建消费",
"edit": "编辑消费",
"TitleField": {
"label": "消费标题",
"placeholder": "周一晚上的餐厅",
"description": "描述这个消费。"
},
"DateField": {
"label": "消费日期",
"description": "输入支付这笔消费的日期。"
},
"categoryFieldDescription": "选择消费类别。",
"paidByField": {
"label": "支付自",
"description": "选择支付这笔消费的群组成员。"
},
"paidFor": {
"title": "支付给",
"description": "选择消费是为谁而支出。"
},
"splitModeDescription": "选择如何划分这笔消费。",
"attachDescription": "查看并为这笔消费附加收据。"
},
"amountField": {
"label": "金额"
},
"isReimbursementField": {
"label": "这是一笔报销款"
},
"categoryField": {
"label": "类别"
},
"notesField": {
"label": "附注"
},
"selectNone": "取消选中",
"selectAll": "全选",
"shares": "份额",
"advancedOptions": "高级分账选项……",
"SplitModeField": {
"label": "分账模式",
"evenly": "平均分配",
"byShares": "按份额分配",
"byPercentage": "按百分比分配",
"byAmount": "按金额分配",
"saveAsDefault": "保存为默认分账设置"
},
"DeletePopup": {
"label": "删除",
"title": "要删除这项消费吗?",
"description": "你真的确定要删除这项消费吗?此行动不可撤销。",
"yes": "确定",
"cancel": "取消"
},
"attachDocuments": "附加文档",
"create": "创建",
"creating": "创建中……",
"save": "保存",
"saving": "保存中……",
"cancel": "取消",
"reimbursement": "报销"
},
"ExpenseDocumentsInput": {
"TooBigToast": {
"title": "文件过大",
"description": "可上传的最大文件大小为{maxSize},你的文件为${size}。"
},
"ErrorToast": {
"title": "上传文档时发生错误",
"description": "上传文档时发生了一些错误。请稍后重试或更换文件。",
"retry": "重试"
}
},
"CreateFromReceipt": {
"Dialog": {
"triggerTitle": "从收据中创建消费",
"title": "从收据中创建",
"description": "从收据照片上提取消费信息。",
"body": "上传收据的图片,我们会尽可能地从中扫描出消费信息。",
"selectImage": "选择图片……",
"titleLabel": "标题:",
"categoryLabel": "类别:",
"amountLabel": "金额:",
"dateLabel": "日期:",
"editNext": "你之后可以修改消费的信息。",
"continue": "继续"
},
"unknown": "未知",
"TooBigToast": {
"title": "文件过大",
"description": "可上传的最大文件大小为{maxSize},你的文件为${size}。"
},
"ErrorToast": {
"title": "上传文档时发生错误",
"description": "上传文档时发生了一些错误。请稍后重试或更换文件。",
"retry": "重试"
}
},
"Balances": {
"title": "余额",
"description": "这是每位群组成员支付或被支付的金额。",
"Reimbursements": {
"title": "建议报销",
"description": "这里是优化群组成员之间报销的建议。",
"noImbursements": "看起来你的群组不需要任何报销😁",
"owes": "<strong>{from}</strong> 欠 <strong>{to}</strong>",
"markAsPaid": "标记为已支付"
}
},
"Stats": {
"title": "统计",
"Totals": {
"title": "总计",
"description": "整个群组的花费合计。",
"groupSpendings": "群组总计开销",
"groupEarnings": "群组总计收入",
"yourSpendings": "你的总计开销",
"yourEarnings": "你的总计收入",
"yourShare": "你的总计份额"
}
},
"Activity": {
"title": "活动",
"description": "该群组所有活动总览",
"noActivity": "你的群组目前没有任何活动。",
"someone": "某人",
"settingsModified": "群组设定已被<strong>{participant}</strong>更改。",
"expenseCreated": "消费 <em>{expense}</em> 由 <strong>{participant}</strong> 创建。",
"expenseUpdated": "消费 <em>{expense}</em> 由 <strong>{participant}</strong> 更新。",
"expenseDeleted": "消费 <em>{expense}</em> 由 <strong>{participant}</strong> 删除。",
"Groups": {
"today": "今天",
"yesterday": "昨天",
"earlierThisWeek": "这周早些时候",
"lastWeek": "上周",
"earlierThisMonth": "这月早些时候",
"lastMonth": "上月",
"earlierThisYear": "这年早些时候",
"lastYear": "上年",
"older": "更早"
}
},
"Information": {
"title": "信息",
"description": "使用此处以添加与群组成员相关的任何信息。",
"empty": "当前没有群组信息。"
},
"Settings": {
"title": "设定"
},
"Locale": {
"en-US": "English",
"fi": "Suomi",
"fr-FR": "Français",
"es": "Español",
"de-DE": "Deutsch",
"zh-CN": "Chinese (Simplified)",
"pl-PL": "Polski",
"ru-RU": "Русский",
"it-IT": "Italiano",
"ua-UA": "Українська",
"ro": "Română"
},
"Share": {
"title": "分享",
"description": "请将此URL分享给其他群组成员以使其可以查看群组并添加消费。",
"warning": "警告!",
"warningHelp": "任何持有群组URL的个体都有能够查看并编辑消费。请谨慎分享"
},
"SchemaErrors": {
"min1": "输入至少1个字符。",
"min2": "输入至少2个字符。",
"max5": "输入至少5个字符。",
"max50": "输入至少50个字符。",
"duplicateParticipantName": "此名字已被另一位群组成员占用。",
"titleRequired": "请输入标题。",
"invalidNumber": "无效数值。",
"amountRequired": "你必须输入一个金额。",
"amountNotZero": "金额不可以为0。",
"amountTenMillion": "金额必须小于10,000,000。",
"paidByRequired": "你必须选择一个群组成员。",
"paidForMin1": "这项消费必须支付给至少1名群组成员。",
"noZeroShares": "所有份额必须大于0。",
"amountSum": "金额之和必须等于消费的金额。",
"percentageSum": "百分比之和必须等于100。"
},
"Categories": {
"search": "搜寻类别……",
"noCategory": "未找到类别。",
"Uncategorized": {
"heading": "未分类",
"General": "一般",
"Payment": "支付"
},
"Entertainment": {
"heading": "娱乐",
"Entertainment": "娱乐",
"Games": "游戏",
"Movies": "电影",
"Music": "音乐",
"Sports": "运动"
},
"Food and Drink": {
"heading": "饮食",
"Food and Drink": "饮食",
"Dining Out": "下馆子",
"Groceries": "便利店",
"Liquor": "酒水"
},
"Home": {
"heading": "居家",
"Home": "居家",
"Electronics": "电费",
"Furniture": "家具",
"Household Supplies": "家庭日用品",
"Maintenance": "维护",
"Mortgage": "贷款",
"Pets": "宠物",
"Rent": "租金",
"Services": "服务"
},
"Life": {
"heading": "生活",
"Childcare": "儿童保育",
"Clothing": "衣物",
"Education": "教育",
"Gifts": "礼物",
"Insurance": "保险",
"Medical Expenses": "医疗支出",
"Taxes": "税"
},
"Transportation": {
"heading": "交通",
"Transportation": "交通",
"Bicycle": "自行车",
"Bus/Train": "巴士/列车",
"Car": "汽车",
"Gas/Fuel": "燃料",
"Hotel": "旅馆",
"Parking": "停车",
"Plane": "飞机",
"Taxi": "出租车"
},
"Utilities": {
"heading": "日常账单",
"Utilities": "日常账单",
"Cleaning": "清洁费",
"Electricity": "电费",
"Heat/Gas": "暖气/瓦斯",
"Trash": "垃圾",
"TV/Phone/Internet": "电视/手机/互联网",
"Water": "水"
}
}
}

View File

@@ -1,4 +1,8 @@
/**
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin()
/**
* Undefined entries are not supported. Push optional patterns to this array only if defined.
* @type {import('next/dist/shared/lib/image-config').RemotePattern}
*/
@@ -31,4 +35,4 @@ const nextConfig = {
},
}
module.exports = nextConfig
export default withNextIntl(nextConfig)

10461
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,11 @@
"prettier": "prettier -w src",
"postinstall": "prisma migrate deploy && prisma generate",
"build-image": "./scripts/build-image.sh",
"start-container": "docker compose --env-file container.env up"
"start-container": "docker compose --env-file container.env up",
"test": "jest"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@hookform/resolvers": "^3.3.2",
"@prisma/client": "^5.6.0",
"@radix-ui/react-checkbox": "^1.0.4",
@@ -31,7 +33,12 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@tailwindcss/typography": "^0.5.10",
"@tanstack/react-query": "^5.59.15",
"@trpc/client": "^11.0.0-rc.586",
"@trpc/react-query": "^11.0.0-rc.586",
"@trpc/server": "^11.0.0-rc.586",
"class-variance-authority": "^0.7.0",
"client-only": "^0.0.1",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"content-disposition": "^0.5.4",
@@ -39,7 +46,9 @@
"embla-carousel-react": "^8.0.0-rc21",
"lucide-react": "^0.290.0",
"nanoid": "^5.0.4",
"next": "^14.2.3",
"negotiator": "^0.6.3",
"next": "^14.2.5",
"next-intl": "^3.17.2",
"next-s3-upload": "^0.3.4",
"next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1",
@@ -50,17 +59,25 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.47.0",
"react-intersection-observer": "^9.8.0",
"server-only": "^0.0.1",
"sharp": "^0.33.2",
"superjson": "^2.2.1",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6",
"use-debounce": "^10.0.4",
"uuid": "^9.0.1",
"vaul": "^0.8.0",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@total-typescript/ts-reset": "^0.5.1",
"@types/content-disposition": "^0.5.8",
"@types/jest": "^29.5.12",
"@types/negotiator": "^0.6.3",
"@types/node": "^20",
"@types/pg": "^8.10.9",
"@types/react": "^18.2.48",
@@ -70,10 +87,13 @@
"dotenv": "^16.3.1",
"eslint": "^8",
"eslint-config-next": "^14.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8",
"prettier": "^3.0.3",
"prettier-plugin-organize-imports": "^3.2.3",
"tailwindcss": "^3",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
import { createTRPCContext } from '@/trpc/init'
import { appRouter } from '@/trpc/routers/_app'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
})
export { handler as GET, handler as POST }

View File

@@ -1,50 +1,44 @@
'use client'
import { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api'
import { DateTimeStyle, cn, formatDate } from '@/lib/utils'
import { Activity, ActivityType, Participant } from '@prisma/client'
import { AppRouterOutput } from '@/trpc/routers/_app'
import { ActivityType, Participant } from '@prisma/client'
import { ChevronRight } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
export type Activity =
AppRouterOutput['groups']['activities']['list']['activities'][number]
type Props = {
groupId: string
activity: Activity
participant?: Participant
expense?: Awaited<ReturnType<typeof getGroupExpenses>>[number]
dateStyle: DateTimeStyle
}
function getSummary(activity: Activity, participantName?: string) {
const participant = participantName ?? 'Someone'
function useSummary(activity: Activity, participantName?: string) {
const t = useTranslations('Activity')
const participant = participantName ?? t('someone')
const expense = activity.data ?? ''
const tr = (key: string) =>
t.rich(key, {
expense,
participant,
em: (chunks) => <em>&ldquo;{chunks}&rdquo;</em>,
strong: (chunks) => <strong>{chunks}</strong>,
})
if (activity.activityType == ActivityType.UPDATE_GROUP) {
return (
<>
Group settings were modified by <strong>{participant}</strong>
</>
)
return <>{tr('settingsModified')}</>
} else if (activity.activityType == ActivityType.CREATE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> created by{' '}
<strong>{participant}</strong>.
</>
)
return <>{tr('expenseCreated')}</>
} else if (activity.activityType == ActivityType.UPDATE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> updated by{' '}
<strong>{participant}</strong>.
</>
)
return <>{tr('expenseUpdated')}</>
} else if (activity.activityType == ActivityType.DELETE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> deleted by{' '}
<strong>{participant}</strong>.
</>
)
return <>{tr('expenseDeleted')}</>
}
}
@@ -52,13 +46,13 @@ export function ActivityItem({
groupId,
activity,
participant,
expense,
dateStyle,
}: Props) {
const router = useRouter()
const locale = useLocale()
const expenseExists = expense !== undefined
const summary = getSummary(activity, participant?.name)
const expenseExists = activity.expense !== undefined
const summary = useSummary(activity, participant?.name)
return (
<div
@@ -75,11 +69,11 @@ export function ActivityItem({
<div className="flex flex-col justify-between items-start">
{dateStyle !== undefined && (
<div className="mt-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { dateStyle })}
{formatDate(activity.time, locale, { dateStyle })}
</div>
)}
<div className="my-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { timeStyle: 'short' })}
{formatDate(activity.time, locale, { timeStyle: 'short' })}
</div>
</div>
<div className="flex-1">

View File

@@ -1,25 +1,27 @@
import { ActivityItem } from '@/app/groups/[groupId]/activity/activity-item'
import { getGroupExpenses } from '@/lib/api'
import { Activity, Participant } from '@prisma/client'
'use client'
import {
Activity,
ActivityItem,
} from '@/app/groups/[groupId]/activity/activity-item'
import { Skeleton } from '@/components/ui/skeleton'
import { trpc } from '@/trpc/client'
import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl'
import { forwardRef, useEffect } from 'react'
import { useInView } from 'react-intersection-observer'
type Props = {
groupId: string
participants: Participant[]
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
activities: Activity[]
}
const PAGE_SIZE = 20
const DATE_GROUPS = {
TODAY: 'Today',
YESTERDAY: 'Yesterday',
EARLIER_THIS_WEEK: 'Earlier this week',
LAST_WEEK: 'Last week',
EARLIER_THIS_MONTH: 'Earlier this month',
LAST_MONTH: 'Last month',
EARLIER_THIS_YEAR: 'Earlier this year',
LAST_YEAR: 'Last year',
OLDER: 'Older',
TODAY: 'today',
YESTERDAY: 'yesterday',
EARLIER_THIS_WEEK: 'earlierThisWeek',
LAST_WEEK: 'lastWeek',
EARLIER_THIS_MONTH: 'earlierThisMonth',
LAST_MONTH: 'lastMonth',
EARLIER_THIS_YEAR: 'earlierThisYear',
LAST_YEAR: 'lastYear',
OLDER: 'older',
}
function getDateGroup(date: Dayjs, today: Dayjs) {
@@ -47,22 +49,64 @@ function getDateGroup(date: Dayjs, today: Dayjs) {
function getGroupedActivitiesByDate(activities: Activity[]) {
const today = dayjs()
return activities.reduce(
(result: { [key: string]: Activity[] }, activity: Activity) => {
(result, activity) => {
const activityGroup = getDateGroup(dayjs(activity.time), today)
result[activityGroup] = result[activityGroup] ?? []
result[activityGroup].push(activity)
return result
},
{},
{} as {
[key: string]: Activity[]
},
)
}
export function ActivityList({
groupId,
participants,
expenses,
activities,
}: Props) {
const ActivitiesLoading = forwardRef<HTMLDivElement>((_, ref) => {
return (
<div ref={ref} className="flex flex-col gap-4">
<Skeleton className="mt-2 h-3 w-24" />
{Array(5)
.fill(undefined)
.map((_, index) => (
<div key={index} className="flex gap-2 p-2">
<div className="flex-0">
<Skeleton className="h-3 w-12" />
</div>
<div className="flex-1">
<Skeleton className="h-3 w-48" />
</div>
</div>
))}
</div>
)
})
ActivitiesLoading.displayName = 'ActivitiesLoading'
export function ActivityList({ groupId }: { groupId: string }) {
const t = useTranslations('Activity')
const { data: groupData, isLoading: groupIsLoading } =
trpc.groups.get.useQuery({ groupId })
const {
data: activitiesData,
isLoading,
fetchNextPage,
} = trpc.groups.activities.list.useInfiniteQuery(
{ groupId, limit: PAGE_SIZE },
{ getNextPageParam: ({ nextCursor }) => nextCursor },
)
const { ref: loadingRef, inView } = useInView()
const activities = activitiesData?.pages.flatMap((page) => page.activities)
const hasMore = activitiesData?.pages.at(-1)?.hasMore ?? false
useEffect(() => {
if (inView && hasMore && !isLoading) fetchNextPage()
}, [fetchNextPage, hasMore, inView, isLoading])
if (isLoading || !activities || !groupData) return <ActivitiesLoading />
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
return activities.length > 0 ? (
@@ -82,31 +126,31 @@ export function ActivityList({
'text-muted-foreground text-xs py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
}
>
{dateGroup}
{t(`Groups.${dateGroup}`)}
</div>
{groupActivities.map((activity: Activity) => {
{groupActivities.map((activity) => {
const participant =
activity.participantId !== null
? participants.find((p) => p.id === activity.participantId)
: undefined
const expense =
activity.expenseId !== null
? expenses.find((e) => e.id === activity.expenseId)
? groupData.group.participants.find(
(p) => p.id === activity.participantId,
)
: undefined
return (
<ActivityItem
key={activity.id}
{...{ groupId, activity, participant, expense, dateStyle }}
groupId={groupId}
activity={activity}
participant={participant}
dateStyle={dateStyle}
/>
)
})}
</div>
)
})}
{hasMore && <ActivitiesLoading ref={loadingRef} />}
</>
) : (
<p className="px-6 text-sm py-6">
There is not yet any activity in your group.
</p>
<p className="text-sm py-6">{t('noActivity')}</p>
)
}

View File

@@ -0,0 +1,32 @@
import { ActivityList } from '@/app/groups/[groupId]/activity/activity-list'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Metadata } from 'next'
import { useTranslations } from 'next-intl'
export const metadata: Metadata = {
title: 'Activity',
}
export function ActivityPageClient({ groupId }: { groupId: string }) {
const t = useTranslations('Activity')
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<ActivityList groupId={groupId} />
</CardContent>
</Card>
</>
)
}

View File

@@ -1,15 +1,5 @@
import { cached } from '@/app/cached-functions'
import { ActivityList } from '@/app/groups/[groupId]/activity/activity-list'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { getActivities, getGroupExpenses } from '@/lib/api'
import { ActivityPageClient } from '@/app/groups/[groupId]/activity/page.client'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Activity',
@@ -20,32 +10,5 @@ export default async function ActivityPage({
}: {
params: { groupId: string }
}) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(groupId)
const activities = await getActivities(groupId)
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>
Overview of all activity in this group.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<ActivityList
{...{
groupId,
participants: group.participants,
expenses,
activities,
}}
/>
</CardContent>
</Card>
</>
)
return <ActivityPageClient groupId={groupId} />
}

View File

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

View File

@@ -0,0 +1,143 @@
'use client'
import { BalancesList } from '@/app/groups/[groupId]/balances-list'
import { ReimbursementList } from '@/app/groups/[groupId]/reimbursement-list'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { trpc } from '@/trpc/client'
import { useTranslations } from 'next-intl'
import { Fragment, useEffect } from 'react'
export default function BalancesAndReimbursements({
groupId,
}: {
groupId: string
}) {
const utils = trpc.useUtils()
const { data: groupData, isLoading: groupIsLoading } =
trpc.groups.get.useQuery({ groupId })
const { data: balancesData, isLoading: balancesAreLoading } =
trpc.groups.balances.list.useQuery({
groupId,
})
const t = useTranslations('Balances')
useEffect(() => {
// Until we use tRPC more widely and can invalidate the cache on expense
// update, it's easier and safer to invalidate the cache on page load.
utils.groups.balances.invalidate()
}, [utils])
const isLoading =
balancesAreLoading || !balancesData || groupIsLoading || !groupData?.group
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<BalancesLoading
participantCount={groupData?.group.participants.length}
/>
) : (
<BalancesList
balances={balancesData.balances}
participants={groupData.group.participants}
currency={groupData.group.currency}
/>
)}
</CardContent>
</Card>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('Reimbursements.title')}</CardTitle>
<CardDescription>{t('Reimbursements.description')}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<ReimbursementsLoading
participantCount={groupData?.group.participants.length}
/>
) : (
<ReimbursementList
reimbursements={balancesData.reimbursements}
participants={groupData.group.participants}
currency={groupData.group.currency}
groupId={groupData.group.id}
/>
)}
</CardContent>
</Card>
</>
)
}
const ReimbursementsLoading = ({
participantCount = 3,
}: {
participantCount?: number
}) => {
return (
<div className="flex flex-col">
{Array(participantCount - 1)
.fill(undefined)
.map((_, index) => (
<div key={index} className="flex justify-between py-5">
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
)
}
const BalancesLoading = ({
participantCount = 3,
}: {
participantCount?: number
}) => {
return (
<div className="grid grid-cols-2 py-1 gap-y-2">
{Array(participantCount)
.fill(undefined)
.map((_, index) =>
index % 2 === 0 ? (
<Fragment key={index}>
<div className="flex items-center justify-end pr-2">
<Skeleton className="h-3 w-16" />
</div>
<div className="self-start">
<Skeleton
className={`h-7 w-${(index % 3) + 1}/3 rounded-l-none`}
/>
</div>
</Fragment>
) : (
<Fragment key={index}>
<div className="flex items-center justify-end">
<Skeleton
className={`h-7 w-${(index % 3) + 1}/3 rounded-r-none`}
/>
</div>
<div className="flex items-center pl-2">
<Skeleton className="h-3 w-16" />
</div>
</Fragment>
),
)}
</div>
)
}

View File

@@ -1,19 +1,5 @@
import { cached } from '@/app/cached-functions'
import { BalancesList } from '@/app/groups/[groupId]/balances-list'
import { ReimbursementList } from '@/app/groups/[groupId]/reimbursement-list'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { getGroupExpenses } from '@/lib/api'
import {
getBalances,
getPublicBalances,
getSuggestedReimbursements,
} from '@/lib/balances'
import BalancesAndReimbursements from '@/app/groups/[groupId]/balances/balances-and-reimbursements'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
@@ -29,45 +15,5 @@ export default async function GroupPage({
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(groupId)
const balances = getBalances(expenses)
const reimbursements = getSuggestedReimbursements(balances)
const publicBalances = getPublicBalances(reimbursements)
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>Balances</CardTitle>
<CardDescription>
This is the amount that each participant paid or was paid for.
</CardDescription>
</CardHeader>
<CardContent>
<BalancesList
balances={publicBalances}
participants={group.participants}
currency={group.currency}
/>
</CardContent>
</Card>
<Card className="mb-4">
<CardHeader>
<CardTitle>Suggested reimbursements</CardTitle>
<CardDescription>
Here are suggestions for optimized reimbursements between
participants.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ReimbursementList
reimbursements={reimbursements}
participants={group.participants}
currency={group.currency}
groupId={groupId}
/>
</CardContent>
</Card>
</>
)
return <BalancesAndReimbursements groupId={groupId} />
}

View File

@@ -0,0 +1,23 @@
'use client'
import { GroupForm } from '@/components/group-form'
import { trpc } from '@/trpc/client'
export const EditGroup = ({ groupId }: { groupId: string }) => {
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
const { mutateAsync } = trpc.groups.update.useMutation()
const utils = trpc.useUtils()
if (isLoading) return <></>
return (
<GroupForm
group={data?.group}
onSubmit={async (groupFormValues, participantId) => {
await mutateAsync({ groupId, participantId, groupFormValues })
await utils.groups.invalidate()
}}
protectedParticipantIds={data?.participantsWithExpenses}
/>
)
}

View File

@@ -1,9 +1,5 @@
import { cached } from '@/app/cached-functions'
import { GroupForm } from '@/components/group-form'
import { getGroupExpensesParticipants, updateGroup } from '@/lib/api'
import { groupFormSchema } from '@/lib/schemas'
import { EditGroup } from '@/app/groups/[groupId]/edit/edit-group'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
export const metadata: Metadata = {
title: 'Settings',
@@ -14,22 +10,5 @@ export default async function EditGroupPage({
}: {
params: { groupId: string }
}) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
async function updateGroupAction(values: unknown, participantId?: string) {
'use server'
const groupFormValues = groupFormSchema.parse(values)
const group = await updateGroup(groupId, groupFormValues, participantId)
redirect(`/groups/${group.id}`)
}
const protectedParticipantIds = await getGroupExpensesParticipants(groupId)
return (
<GroupForm
group={group}
onSubmit={updateGroupAction}
protectedParticipantIds={protectedParticipantIds}
/>
)
return <EditGroup groupId={groupId} />
}

View File

@@ -1,19 +1,9 @@
import { cached } from '@/app/cached-functions'
import { ExpenseForm } from '@/components/expense-form'
import {
deleteExpense,
getCategories,
getExpense,
updateExpense,
} from '@/lib/api'
import { EditExpenseForm } from '@/app/groups/[groupId]/expenses/edit-expense-form'
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { Suspense } from 'react'
export const metadata: Metadata = {
title: 'Edit expense',
title: 'Edit Expense',
}
export default async function EditExpensePage({
@@ -21,35 +11,11 @@ export default async function EditExpensePage({
}: {
params: { groupId: string; expenseId: string }
}) {
const categories = await getCategories()
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expense = await getExpense(groupId, expenseId)
if (!expense) notFound()
async function updateExpenseAction(values: unknown, participantId?: string) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await updateExpense(groupId, expenseId, expenseFormValues, participantId)
redirect(`/groups/${groupId}`)
}
async function deleteExpenseAction(participantId?: string) {
'use server'
await deleteExpense(groupId, expenseId, participantId)
redirect(`/groups/${groupId}`)
}
return (
<Suspense>
<ExpenseForm
group={group}
expense={expense}
categories={categories}
onSubmit={updateExpenseAction}
onDelete={deleteExpenseAction}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
</Suspense>
<EditExpenseForm
groupId={groupId}
expenseId={expenseId}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
)
}

View File

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

View File

@@ -12,27 +12,30 @@ import {
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { getGroup } from '@/lib/api'
import { useMediaQuery } from '@/lib/hooks'
import { cn } from '@/lib/utils'
import { trpc } from '@/trpc/client'
import { AppRouterOutput } from '@/trpc/routers/_app'
import { useTranslations } from 'next-intl'
import { ComponentProps, useEffect, useState } from 'react'
type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
}
export function ActiveUserModal({ group }: Props) {
export function ActiveUserModal({ groupId }: { groupId: string }) {
const t = useTranslations('Expenses.ActiveUserModal')
const [open, setOpen] = useState(false)
const isDesktop = useMediaQuery('(min-width: 768px)')
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const group = groupData?.group
useEffect(() => {
if (!group) return
const tempUser = localStorage.getItem(`newGroup-activeUser`)
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
if (!tempUser && !activeUser) {
@@ -41,6 +44,8 @@ export function ActiveUserModal({ group }: Props) {
}, [group])
function updateOpen(open: boolean) {
if (!group) return
if (!open && !localStorage.getItem(`${group.id}-activeUser`)) {
localStorage.setItem(`${group.id}-activeUser`, 'None')
}
@@ -52,16 +57,13 @@ export function ActiveUserModal({ group }: Props) {
<Dialog open={open} onOpenChange={updateOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Who are you?</DialogTitle>
<DialogDescription>
Tell us which participant you are to let us customize how the
information is displayed.
</DialogDescription>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<ActiveUserForm group={group} close={() => setOpen(false)} />
<DialogFooter className="sm:justify-center">
<p className="text-sm text-center text-muted-foreground">
This setting can be changed later in the group settings.
{t('footer')}
</p>
</DialogFooter>
</DialogContent>
@@ -73,11 +75,8 @@ export function ActiveUserModal({ group }: Props) {
<Drawer open={open} onOpenChange={updateOpen}>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>Who are you?</DrawerTitle>
<DrawerDescription>
Tell us which participant you are to let us customize how the
information is displayed.
</DrawerDescription>
<DrawerTitle>{t('title')}</DrawerTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DrawerHeader>
<ActiveUserForm
className="px-4"
@@ -86,7 +85,7 @@ export function ActiveUserModal({ group }: Props) {
/>
<DrawerFooter className="pt-2">
<p className="text-sm text-center text-muted-foreground">
This setting can be changed later in the group settings.
{t('footer')}
</p>
</DrawerFooter>
</DrawerContent>
@@ -98,13 +97,19 @@ function ActiveUserForm({
group,
close,
className,
}: ComponentProps<'form'> & { group: Props['group']; close: () => void }) {
}: ComponentProps<'form'> & {
group?: AppRouterOutput['groups']['get']['group']
close: () => void
}) {
const t = useTranslations('Expenses.ActiveUserModal')
const [selected, setSelected] = useState('None')
return (
<form
className={cn('grid items-start gap-4', className)}
onSubmit={(event) => {
if (!group) return
event.preventDefault()
localStorage.setItem(`${group.id}-activeUser`, selected)
close()
@@ -115,10 +120,10 @@ function ActiveUserForm({
<div className="flex items-center space-x-2">
<RadioGroupItem value="none" id="none" />
<Label htmlFor="none" className="italic font-normal flex-1">
I dont want to select anyone
{t('nobody')}
</Label>
</div>
{group.participants.map((participant) => (
{group?.participants.map((participant) => (
<div key={participant.id} className="flex items-center space-x-2">
<RadioGroupItem value={participant.id} id={participant.id} />
<Label htmlFor={participant.id} className="flex-1">
@@ -128,7 +133,7 @@ function ActiveUserForm({
))}
</div>
</RadioGroup>
<Button type="submit">Save changes</Button>
<Button type="submit">{t('save')}</Button>
</form>
)
}

View File

@@ -0,0 +1,45 @@
'use client'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { trpc } from '@/trpc/client'
import { useRouter } from 'next/navigation'
import { ExpenseForm } from './expense-form'
export function CreateExpenseForm({
groupId,
runtimeFeatureFlags,
}: {
groupId: string
expenseId?: string
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const group = groupData?.group
const { data: categoriesData } = trpc.categories.list.useQuery()
const categories = categoriesData?.categories
const { mutateAsync: createExpenseMutateAsync } =
trpc.groups.expenses.create.useMutation()
const utils = trpc.useUtils()
const router = useRouter()
if (!group || !categories) return null
return (
<ExpenseForm
group={group}
categories={categories}
onSubmit={async (expenseFormValues, participantId) => {
await createExpenseMutateAsync({
groupId,
expenseFormValues,
participantId,
})
utils.groups.expenses.invalidate()
router.push(`/groups/${group.id}`)
}}
runtimeFeatureFlags={runtimeFeatureFlags}
/>
)
}

View File

@@ -12,7 +12,7 @@ export async function extractExpenseInformationFromImage(imageUrl: string) {
const categories = await getCategories()
const body: ChatCompletionCreateParamsNonStreaming = {
model: 'gpt-4-vision-preview',
model: 'gpt-4-turbo',
messages: [
{
role: 'user',

View File

@@ -27,26 +27,62 @@ import { ToastAction } from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast'
import { useMediaQuery } from '@/lib/hooks'
import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
import { Category } from '@prisma/client'
import { trpc } from '@/trpc/client'
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { getImageData, usePresignedUpload } from 'next-s3-upload'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { PropsWithChildren, ReactNode, useState } from 'react'
type Props = {
groupId: string
groupCurrency: string
categories: Category[]
}
const MAX_FILE_SIZE = 5 * 1024 ** 2
export function CreateFromReceiptButton({
groupId,
groupCurrency,
categories,
}: Props) {
export function CreateFromReceiptButton({ groupId }: { groupId: string }) {
return <CreateFromReceiptButton_ groupId={groupId} />
}
function CreateFromReceiptButton_({ groupId }: { groupId: string }) {
const t = useTranslations('CreateFromReceipt')
const isDesktop = useMediaQuery('(min-width: 640px)')
const DialogOrDrawer = isDesktop
? CreateFromReceiptDialog
: CreateFromReceiptDrawer
return (
<DialogOrDrawer
trigger={
<Button
size="icon"
variant="secondary"
title={t('Dialog.triggerTitle')}
>
<Receipt className="w-4 h-4" />
</Button>
}
title={
<>
<span>{t('Dialog.title')}</span>
<Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600">
Beta
</Badge>
</>
}
description={<>{t('Dialog.description')}</>}
>
<ReceiptDialogContent groupId={groupId} />
</DialogOrDrawer>
)
}
function ReceiptDialogContent({ groupId }: { groupId: string }) {
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const { data: categoriesData } = trpc.categories.list.useQuery()
const group = groupData?.group
const categories = categoriesData?.categories
const locale = useLocale()
const t = useTranslations('CreateFromReceipt')
const [pending, setPending] = useState(false)
const { uploadToS3, FileInput, openFileDialog } = usePresignedUpload()
const { toast } = useToast()
@@ -55,15 +91,15 @@ export function CreateFromReceiptButton({
| null
| (ReceiptExtractedInfo & { url: string; width?: number; height?: number })
>(null)
const isDesktop = useMediaQuery('(min-width: 640px)')
const handleFileChange = async (file: File) => {
if (file.size > MAX_FILE_SIZE) {
toast({
title: 'The file is too big',
description: `The maximum file size you can upload is ${formatFileSize(
MAX_FILE_SIZE,
)}. Yours is ${formatFileSize(file.size)}.`,
title: t('TooBigToast.title'),
description: t('TooBigToast.description', {
maxSize: formatFileSize(MAX_FILE_SIZE, locale),
size: formatFileSize(file.size, locale),
}),
variant: 'destructive',
})
return
@@ -82,13 +118,15 @@ export function CreateFromReceiptButton({
} catch (err) {
console.error(err)
toast({
title: 'Error while uploading document',
description:
'Something wrong happened when uploading the document. Please retry later or select a different file.',
title: t('ErrorToast.title'),
description: t('ErrorToast.description'),
variant: 'destructive',
action: (
<ToastAction altText="Retry" onClick={() => upload()}>
Retry
<ToastAction
altText={t('ErrorToast.retry')}
onClick={() => upload()}
>
{t('ErrorToast.retry')}
</ToastAction>
),
})
@@ -101,162 +139,139 @@ export function CreateFromReceiptButton({
const receiptInfoCategory =
(receiptInfo?.categoryId &&
categories.find((c) => String(c.id) === receiptInfo.categoryId)) ||
categories?.find((c) => String(c.id) === receiptInfo.categoryId)) ||
null
const DialogOrDrawer = isDesktop
? CreateFromReceiptDialog
: CreateFromReceiptDrawer
return (
<DialogOrDrawer
trigger={
<Button
size="icon"
variant="secondary"
title="Create expense from receipt"
>
<Receipt className="w-4 h-4" />
</Button>
}
title={
<>
<span>Create from receipt</span>
<Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600">
Beta
</Badge>
</>
}
description={<>Extract the expense information from a receipt photo.</>}
>
<div className="prose prose-sm dark:prose-invert">
<p>
Upload the photo of a receipt, and well scan it to extract the
expense information if we can.
</p>
<div>
<FileInput
onChange={handleFileChange}
accept="image/jpeg,image/png"
/>
<div className="grid gap-x-4 gap-y-2 grid-cols-3">
<Button
variant="secondary"
className="row-span-3 w-full h-full relative"
title="Create expense from receipt"
onClick={openFileDialog}
disabled={pending}
>
{pending ? (
<Loader2 className="w-8 h-8 animate-spin" />
) : receiptInfo ? (
<div className="absolute top-2 left-2 bottom-2 right-2">
<Image
src={receiptInfo.url}
width={receiptInfo.width}
height={receiptInfo.height}
className="w-full h-full m-0 object-contain drop-shadow-lg"
alt="Scanned receipt"
/>
</div>
<div className="prose prose-sm dark:prose-invert">
<p>{t('Dialog.body')}</p>
<div>
<FileInput onChange={handleFileChange} accept="image/jpeg,image/png" />
<div className="grid gap-x-4 gap-y-2 grid-cols-3">
<Button
variant="secondary"
className="row-span-3 w-full h-full relative"
title="Create expense from receipt"
onClick={openFileDialog}
disabled={pending}
>
{pending ? (
<Loader2 className="w-8 h-8 animate-spin" />
) : receiptInfo ? (
<div className="absolute top-2 left-2 bottom-2 right-2">
<Image
src={receiptInfo.url}
width={receiptInfo.width}
height={receiptInfo.height}
className="w-full h-full m-0 object-contain drop-shadow-lg"
alt="Scanned receipt"
/>
</div>
) : (
<span className="text-xs sm:text-sm text-muted-foreground">
{t('Dialog.selectImage')}
</span>
)}
</Button>
<div className="col-span-2">
<strong>{t('Dialog.titleLabel')}</strong>
<div>{receiptInfo ? receiptInfo.title ?? <Unknown /> : '…'}</div>
</div>
<div className="col-span-2">
<strong>{t('Dialog.categoryLabel')}</strong>
<div>
{receiptInfo ? (
receiptInfoCategory ? (
<div className="flex items-center">
<CategoryIcon
category={receiptInfoCategory}
className="inline w-4 h-4 mr-2"
/>
<span className="mr-1">{receiptInfoCategory.grouping}</span>
<ChevronRight className="inline w-3 h-3 mr-1" />
<span>{receiptInfoCategory.name}</span>
</div>
) : (
<Unknown />
)
) : (
<span className="text-xs sm:text-sm text-muted-foreground">
Select image
</span>
'' || '…'
)}
</Button>
<div className="col-span-2">
<strong>Title:</strong>
<div>{receiptInfo ? receiptInfo.title ?? <Unknown /> : '…'}</div>
</div>
<div className="col-span-2">
<strong>Category:</strong>
<div>
{receiptInfo ? (
receiptInfoCategory ? (
<div className="flex items-center">
<CategoryIcon
category={receiptInfoCategory}
className="inline w-4 h-4 mr-2"
/>
<span className="mr-1">
{receiptInfoCategory.grouping}
</span>
<ChevronRight className="inline w-3 h-3 mr-1" />
<span>{receiptInfoCategory.name}</span>
</div>
) : (
<Unknown />
)
) : (
'' || '…'
)}
</div>
</div>
</div>
<div>
<strong>{t('Dialog.amountLabel')}</strong>
<div>
<strong>Amount:</strong>
<div>
{receiptInfo ? (
receiptInfo.amount ? (
<>{formatCurrency(groupCurrency, receiptInfo.amount)}</>
) : (
<Unknown />
)
{receiptInfo && group ? (
receiptInfo.amount ? (
<>
{formatCurrency(
group.currency,
receiptInfo.amount,
locale,
true,
)}
</>
) : (
'…'
)}
</div>
<Unknown />
)
) : (
'…'
)}
</div>
</div>
<div>
<strong>{t('Dialog.dateLabel')}</strong>
<div>
<strong>Date:</strong>
<div>
{receiptInfo ? (
receiptInfo.date ? (
formatDate(new Date(`${receiptInfo?.date}T12:00:00.000Z`), {
dateStyle: 'medium',
})
) : (
<Unknown />
{receiptInfo ? (
receiptInfo.date ? (
formatDate(
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
locale,
{ dateStyle: 'medium' },
)
) : (
'…'
)}
</div>
<Unknown />
)
) : (
'…'
)}
</div>
</div>
</div>
<p>Youll be able to edit the expense information next.</p>
<div className="text-center">
<Button
disabled={pending || !receiptInfo}
onClick={() => {
if (!receiptInfo) return
router.push(
`/groups/${groupId}/expenses/create?amount=${
receiptInfo.amount
}&categoryId=${receiptInfo.categoryId}&date=${
receiptInfo.date
}&title=${encodeURIComponent(
receiptInfo.title ?? '',
)}&imageUrl=${encodeURIComponent(receiptInfo.url)}&imageWidth=${
receiptInfo.width
}&imageHeight=${receiptInfo.height}`,
)
}}
>
Continue
</Button>
</div>
</div>
</DialogOrDrawer>
<p>{t('Dialog.editNext')}</p>
<div className="text-center">
<Button
disabled={pending || !receiptInfo}
onClick={() => {
if (!receiptInfo || !group) return
router.push(
`/groups/${group.id}/expenses/create?amount=${
receiptInfo.amount
}&categoryId=${receiptInfo.categoryId}&date=${
receiptInfo.date
}&title=${encodeURIComponent(
receiptInfo.title ?? '',
)}&imageUrl=${encodeURIComponent(receiptInfo.url)}&imageWidth=${
receiptInfo.width
}&imageHeight=${receiptInfo.height}`,
)
}}
>
{t('Dialog.continue')}
</Button>
</div>
</div>
)
}
function Unknown() {
const t = useTranslations('CreateFromReceipt')
return (
<div className="flex gap-1 items-center text-muted-foreground">
<FileQuestion className="w-4 h-4" />
<em>Unknown</em>
<em>{t('unknown')}</em>
</div>
)
}

View File

@@ -1,14 +1,9 @@
import { cached } from '@/app/cached-functions'
import { ExpenseForm } from '@/components/expense-form'
import { createExpense, getCategories } from '@/lib/api'
import { CreateExpenseForm } from '@/app/groups/[groupId]/expenses/create-expense-form'
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { Suspense } from 'react'
export const metadata: Metadata = {
title: 'Create expense',
title: 'Create Expense',
}
export default async function ExpensePage({
@@ -16,25 +11,10 @@ export default async function ExpensePage({
}: {
params: { groupId: string }
}) {
const categories = await getCategories()
const group = await cached.getGroup(groupId)
if (!group) notFound()
async function createExpenseAction(values: unknown, participantId?: string) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await createExpense(expenseFormValues, groupId, participantId)
redirect(`/groups/${groupId}`)
}
return (
<Suspense>
<ExpenseForm
group={group}
categories={categories}
onSubmit={createExpenseAction}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
</Suspense>
<CreateExpenseForm
groupId={groupId}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { trpc } from '@/trpc/client'
import { useRouter } from 'next/navigation'
import { ExpenseForm } from './expense-form'
export function EditExpenseForm({
groupId,
expenseId,
runtimeFeatureFlags,
}: {
groupId: string
expenseId: string
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const group = groupData?.group
const { data: categoriesData } = trpc.categories.list.useQuery()
const categories = categoriesData?.categories
const { data: expenseData } = trpc.groups.expenses.get.useQuery({
groupId,
expenseId,
})
const expense = expenseData?.expense
const { mutateAsync: updateExpenseMutateAsync } =
trpc.groups.expenses.update.useMutation()
const { mutateAsync: deleteExpenseMutateAsync } =
trpc.groups.expenses.delete.useMutation()
const utils = trpc.useUtils()
const router = useRouter()
if (!group || !categories || !expense) return null
return (
<ExpenseForm
group={group}
expense={expense}
categories={categories}
onSubmit={async (expenseFormValues, participantId) => {
await updateExpenseMutateAsync({
expenseId,
groupId,
expenseFormValues,
participantId,
})
utils.groups.expenses.invalidate()
router.push(`/groups/${group.id}`)
}}
onDelete={async (participantId) => {
await deleteExpenseMutateAsync({
expenseId,
groupId,
participantId,
})
utils.groups.expenses.invalidate()
router.push(`/groups/${group.id}`)
}}
runtimeFeatureFlags={runtimeFeatureFlags}
/>
)
}

View File

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

View File

@@ -1,4 +1,3 @@
'use client'
import { CategorySelector } from '@/components/category-selector'
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
import { SubmitButton } from '@/components/submit-button'
@@ -33,7 +32,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
import { randomId } from '@/lib/api'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { useActiveUser } from '@/lib/hooks'
import {
@@ -42,25 +41,18 @@ import {
expenseFormSchema,
} from '@/lib/schemas'
import { cn } from '@/lib/utils'
import { AppRouterOutput } from '@/trpc/routers/_app'
import { zodResolver } from '@hookform/resolvers/zod'
import { Save } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { match } from 'ts-pattern'
import { DeletePopup } from './delete-popup'
import { extractCategoryFromTitle } from './expense-form-actions'
import { Textarea } from './ui/textarea'
export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
onSubmit: (values: ExpenseFormValues, participantId?: string) => Promise<void>
onDelete?: (participantId?: string) => Promise<void>
runtimeFeatureFlags: RuntimeFeatureFlags
}
import { DeletePopup } from '../../../../components/delete-popup'
import { extractCategoryFromTitle } from '../../../../components/expense-form-actions'
import { Textarea } from '../../../../components/ui/textarea'
const enforceCurrencyPattern = (value: string) =>
value
@@ -71,10 +63,9 @@ const enforceCurrencyPattern = (value: string) =>
.replace(/#/, '.') // change back # to dot
.replace(/[^-\d.]/g, '') // remove all non-numeric characters
const capitalize = (value: string) =>
value.charAt(0).toUpperCase() + value.slice(1)
const getDefaultSplittingOptions = (group: Props['group']) => {
const getDefaultSplittingOptions = (
group: AppRouterOutput['groups']['get']['group'],
) => {
const defaultValue = {
splitMode: 'EVENLY' as const,
paidFor: group.participants.map(({ id }) => ({
@@ -148,14 +139,23 @@ async function persistDefaultSplittingOptions(
export function ExpenseForm({
group,
expense,
categories,
expense,
onSubmit,
onDelete,
runtimeFeatureFlags,
}: Props) {
}: {
group: AppRouterOutput['groups']['get']['group']
categories: AppRouterOutput['categories']['list']['categories']
expense?: AppRouterOutput['groups']['expenses']['get']['expense']
onSubmit: (value: ExpenseFormValues, participantId?: string) => Promise<void>
onDelete?: (participantId?: string) => Promise<void>
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const t = useTranslations('ExpenseForm')
const isCreate = expense === undefined
const searchParams = useSearchParams()
const getSelectedPayer = (field?: { value: string }) => {
if (isCreate && typeof window !== 'undefined') {
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
@@ -187,7 +187,7 @@ export function ExpenseForm({
}
: searchParams.get('reimbursement')
? {
title: 'Reimbursement',
title: t('reimbursement'),
expenseDate: new Date(),
amount: String(
(Number(searchParams.get('amount')) || 0) / 100,
@@ -245,15 +245,79 @@ export function ExpenseForm({
}
const [isIncome, setIsIncome] = useState(Number(form.getValues().amount) < 0)
const sExpense = isIncome ? 'income' : 'expense'
const [manuallyEditedParticipants, setManuallyEditedParticipants] = useState<
Set<string>
>(new Set())
const sExpense = isIncome ? 'Income' : 'Expense'
const sPaid = isIncome ? 'received' : 'paid'
useEffect(() => {
setManuallyEditedParticipants(new Set())
}, [form.watch('splitMode'), form.watch('amount')])
useEffect(() => {
const splitMode = form.getValues().splitMode
// Only auto-balance for split mode 'Unevenly - By amount'
if (
splitMode === 'BY_AMOUNT' &&
(form.getFieldState('paidFor').isDirty ||
form.getFieldState('amount').isDirty)
) {
const totalAmount = Number(form.getValues().amount) || 0
const paidFor = form.getValues().paidFor
let newPaidFor = [...paidFor]
const editedParticipants = Array.from(manuallyEditedParticipants)
let remainingAmount = totalAmount
let remainingParticipants = newPaidFor.length - editedParticipants.length
newPaidFor = newPaidFor.map((participant) => {
if (editedParticipants.includes(participant.participant)) {
const participantShare = Number(participant.shares) || 0
if (splitMode === 'BY_AMOUNT') {
remainingAmount -= participantShare
}
return participant
}
return participant
})
if (remainingParticipants > 0) {
let amountPerRemaining = 0
if (splitMode === 'BY_AMOUNT') {
amountPerRemaining = remainingAmount / remainingParticipants
}
newPaidFor = newPaidFor.map((participant) => {
if (!editedParticipants.includes(participant.participant)) {
return {
...participant,
shares: String(
Number(amountPerRemaining.toFixed(2)),
) as unknown as number,
}
}
return participant
})
}
form.setValue('paidFor', newPaidFor, { shouldValidate: true })
}
}, [
manuallyEditedParticipants,
form.watch('amount'),
form.watch('splitMode'),
])
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<Card>
<CardHeader>
<CardTitle>{(isCreate ? 'Create ' : 'Edit ') + sExpense}</CardTitle>
<CardTitle>
{t(`${sExpense}.${isCreate ? 'create' : 'edit'}`)}
</CardTitle>
</CardHeader>
<CardContent className="grid sm:grid-cols-2 gap-6">
<FormField
@@ -261,10 +325,10 @@ export function ExpenseForm({
name="title"
render={({ field }) => (
<FormItem className="">
<FormLabel>{capitalize(sExpense)} title</FormLabel>
<FormLabel>{t(`${sExpense}.TitleField.label`)}</FormLabel>
<FormControl>
<Input
placeholder="Monday evening restaurant"
placeholder={t(`${sExpense}.TitleField.placeholder`)}
className="text-base"
{...field}
onBlur={async () => {
@@ -281,7 +345,7 @@ export function ExpenseForm({
/>
</FormControl>
<FormDescription>
Enter a description for the {sExpense}.
{t(`${sExpense}.TitleField.description`)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -293,7 +357,7 @@ export function ExpenseForm({
name="expenseDate"
render={({ field }) => (
<FormItem className="sm:order-1">
<FormLabel>{capitalize(sExpense)} date</FormLabel>
<FormLabel>{t(`${sExpense}.DateField.label`)}</FormLabel>
<FormControl>
<Input
className="date-base"
@@ -305,7 +369,7 @@ export function ExpenseForm({
/>
</FormControl>
<FormDescription>
Enter the date the {sExpense} was {sPaid}.
{t(`${sExpense}.DateField.description`)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -317,7 +381,7 @@ export function ExpenseForm({
name="amount"
render={({ field: { onChange, ...field } }) => (
<FormItem className="sm:order-3">
<FormLabel>Amount</FormLabel>
<FormLabel>{t('amountField.label')}</FormLabel>
<div className="flex items-baseline gap-2">
<span>{group.currency}</span>
<FormControl>
@@ -357,7 +421,9 @@ export function ExpenseForm({
/>
</FormControl>
<div>
<FormLabel>This is a reimbursement</FormLabel>
<FormLabel>
{t('isReimbursementField.label')}
</FormLabel>
</div>
</FormItem>
)}
@@ -372,7 +438,7 @@ export function ExpenseForm({
name="category"
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>Category</FormLabel>
<FormLabel>{t('categoryField.label')}</FormLabel>
<CategorySelector
categories={categories}
defaultValue={
@@ -382,7 +448,7 @@ export function ExpenseForm({
isLoading={isCategoryLoading}
/>
<FormDescription>
Select the {sExpense} category.
{t(`${sExpense}.categoryFieldDescription`)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -394,7 +460,7 @@ export function ExpenseForm({
name="paidBy"
render={({ field }) => (
<FormItem className="sm:order-5">
<FormLabel>{capitalize(sPaid)} by</FormLabel>
<FormLabel>{t(`${sExpense}.paidByField.label`)}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={getSelectedPayer(field)}
@@ -411,7 +477,7 @@ export function ExpenseForm({
</SelectContent>
</Select>
<FormDescription>
Select the participant who {sPaid} the {sExpense}.
{t(`${sExpense}.paidByField.description`)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -422,7 +488,7 @@ export function ExpenseForm({
name="notes"
render={({ field }) => (
<FormItem className="sm:order-6">
<FormLabel>Notes</FormLabel>
<FormLabel>{t('notesField.label')}</FormLabel>
<FormControl>
<Textarea className="text-base" {...field} />
</FormControl>
@@ -435,7 +501,7 @@ export function ExpenseForm({
<Card className="mt-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>{capitalize(sPaid)} for</span>
<span>{t(`${sExpense}.paidFor.title`)}</span>
<Button
variant="link"
type="button"
@@ -461,14 +527,14 @@ export function ExpenseForm({
>
{form.getValues().paidFor.length ===
group.participants.length ? (
<>Select none</>
<>{t('selectNone')}</>
) : (
<>Select all</>
<>{t('selectAll')}</>
)}
</Button>
</CardTitle>
<CardDescription>
Select who the {sExpense} was {sPaid} for.
{t(`${sExpense}.paidFor.description`)}
</CardDescription>
</CardHeader>
<CardContent>
@@ -497,18 +563,29 @@ export function ExpenseForm({
({ participant }) => participant === id,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...field.value,
{
participant: id,
shares: '1',
},
])
: field.onChange(
const options = {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
}
checked
? form.setValue(
'paidFor',
[
...field.value,
{
participant: id,
shares: '1' as unknown as number,
},
],
options,
)
: form.setValue(
'paidFor',
field.value?.filter(
(value) => value.participant !== id,
),
options,
)
}}
/>
@@ -533,7 +610,9 @@ export function ExpenseForm({
})}
>
{match(form.getValues().splitMode)
.with('BY_SHARES', () => <>share(s)</>)
.with('BY_SHARES', () => (
<>{t('shares')}</>
))
.with('BY_PERCENTAGE', () => <>%</>)
.with('BY_AMOUNT', () => (
<>{group.currency}</>
@@ -570,7 +649,7 @@ export function ExpenseForm({
participant === id,
)?.shares
}
onChange={(event) =>
onChange={(event) => {
field.onChange(
field.value.map((p) =>
p.participant === id
@@ -584,7 +663,10 @@ export function ExpenseForm({
: p,
),
)
}
setManuallyEditedParticipants(
(prev) => new Set(prev).add(id),
)
}}
inputMode={
form.getValues().splitMode ===
'BY_AMOUNT'
@@ -628,7 +710,7 @@ export function ExpenseForm({
>
<CollapsibleTrigger asChild>
<Button variant="link" className="-mx-4">
Advanced splitting options
{t('advancedOptions')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -638,7 +720,7 @@ export function ExpenseForm({
name="splitMode"
render={({ field }) => (
<FormItem>
<FormLabel>Split mode</FormLabel>
<FormLabel>{t('SplitModeField.label')}</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
@@ -654,21 +736,23 @@ export function ExpenseForm({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVENLY">Evenly</SelectItem>
<SelectItem value="EVENLY">
{t('SplitModeField.evenly')}
</SelectItem>
<SelectItem value="BY_SHARES">
Unevenly By shares
{t('SplitModeField.byShares')}
</SelectItem>
<SelectItem value="BY_PERCENTAGE">
Unevenly By percentage
{t('SplitModeField.byPercentage')}
</SelectItem>
<SelectItem value="BY_AMOUNT">
Unevenly By amount
{t('SplitModeField.byAmount')}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Select how to split the {sExpense}.
{t(`${sExpense}.splitModeDescription`)}
</FormDescription>
</FormItem>
)}
@@ -686,7 +770,7 @@ export function ExpenseForm({
</FormControl>
<div>
<FormLabel>
Save as default splitting options
{t('SplitModeField.saveAsDefault')}
</FormLabel>
</div>
</FormItem>
@@ -702,10 +786,10 @@ export function ExpenseForm({
<Card className="mt-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>Attach documents</span>
<span>{t('attachDocuments')}</span>
</CardTitle>
<CardDescription>
See and attach receipts to the {sExpense}.
{t(`${sExpense}.attachDescription`)}
</CardDescription>
</CardHeader>
<CardContent>
@@ -724,11 +808,9 @@ export function ExpenseForm({
)}
<div className="flex mt-4 gap-2">
<SubmitButton
loadingContent={isCreate ? <>Creating</> : <>Saving</>}
>
<SubmitButton loadingContent={t(isCreate ? 'creating' : 'saving')}>
<Save className="w-4 h-4 mr-2" />
{isCreate ? <>Create</> : <>Save</>}
{t(isCreate ? 'create' : 'save')}
</SubmitButton>
{!isCreate && onDelete && (
<DeletePopup
@@ -736,7 +818,7 @@ export function ExpenseForm({
></DeletePopup>
)}
<Button variant="ghost" asChild>
<Link href={`/groups/${group.id}`}>Cancel</Link>
<Link href={`/groups/${group.id}`}>{t('cancel')}</Link>
</Button>
</div>
</form>

View File

@@ -4,32 +4,28 @@ import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense-
import { Button } from '@/components/ui/button'
import { SearchBar } from '@/components/ui/search-bar'
import { Skeleton } from '@/components/ui/skeleton'
import { Participant } from '@prisma/client'
import { trpc } from '@/trpc/client'
import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { forwardRef, useEffect, useMemo, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { useDebounce } from 'use-debounce'
const PAGE_SIZE = 20
type ExpensesType = NonNullable<
Awaited<ReturnType<typeof getGroupExpensesAction>>
>
type Props = {
expensesFirstPage: ExpensesType
expenseCount: number
participants: Participant[]
currency: string
groupId: string
}
const EXPENSE_GROUPS = {
UPCOMING: 'Upcoming',
THIS_WEEK: 'This week',
EARLIER_THIS_MONTH: 'Earlier this month',
LAST_MONTH: 'Last month',
EARLIER_THIS_YEAR: 'Earlier this year',
LAST_YEAR: 'Last year',
OLDER: 'Older',
UPCOMING: 'upcoming',
THIS_WEEK: 'thisWeek',
EARLIER_THIS_MONTH: 'earlierThisMonth',
LAST_MONTH: 'lastMonth',
EARLIER_THIS_YEAR: 'earlierThisYear',
LAST_YEAR: 'lastYear',
OLDER: 'older',
}
function getExpenseGroup(date: Dayjs, today: Dayjs) {
@@ -60,23 +56,16 @@ function getGroupedExpensesByDate(expenses: ExpensesType) {
}, {})
}
export function ExpenseList({
expensesFirstPage,
expenseCount,
currency,
participants,
groupId,
}: Props) {
const firstLen = expensesFirstPage.length
export function ExpenseList({ groupId }: { groupId: string }) {
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const [searchText, setSearchText] = useState('')
const [dataIndex, setDataIndex] = useState(firstLen)
const [dataLen, setDataLen] = useState(firstLen)
const [hasMoreData, setHasMoreData] = useState(expenseCount > firstLen)
const [isFetching, setIsFetching] = useState(false)
const [expenses, setExpenses] = useState(expensesFirstPage)
const { ref, inView } = useInView()
const [debouncedSearchText] = useDebounce(searchText, 300)
const participants = groupData?.group.participants
useEffect(() => {
if (!participants) return
const activeUser = localStorage.getItem('newGroup-activeUser')
const newUser = localStorage.getItem(`${groupId}-newUser`)
if (activeUser || newUser) {
@@ -95,55 +84,80 @@ export function ExpenseList({
}
}, [groupId, participants])
return (
<>
<SearchBar onValueChange={(value) => setSearchText(value)} />
<ExpenseListForSearch
groupId={groupId}
searchText={debouncedSearchText}
/>
</>
)
}
const ExpenseListForSearch = ({
groupId,
searchText,
}: {
groupId: string
searchText: string
}) => {
const utils = trpc.useUtils()
useEffect(() => {
const fetchNextPage = async () => {
setIsFetching(true)
// Until we use tRPC more widely and can invalidate the cache on expense
// update, it's easier and safer to invalidate the cache on page load.
utils.groups.expenses.invalidate()
}, [utils])
const newExpenses = await getGroupExpensesAction(groupId, {
offset: dataIndex,
length: dataLen,
})
const t = useTranslations('Expenses')
const { ref: loadingRef, inView } = useInView()
if (newExpenses !== null) {
const exp = expenses.concat(newExpenses)
setExpenses(exp)
setHasMoreData(exp.length < expenseCount)
setDataIndex(dataIndex + dataLen)
setDataLen(Math.ceil(1.5 * dataLen))
}
const {
data,
isLoading: expensesAreLoading,
fetchNextPage,
} = trpc.groups.expenses.list.useInfiniteQuery(
{ groupId, limit: PAGE_SIZE, filter: searchText },
{ getNextPageParam: ({ nextCursor }) => nextCursor },
)
const expenses = data?.pages.flatMap((page) => page.expenses)
const hasMore = data?.pages.at(-1)?.hasMore ?? false
setTimeout(() => setIsFetching(false), 500)
}
const { data: groupData, isLoading: groupIsLoading } =
trpc.groups.get.useQuery({ groupId })
if (inView && hasMoreData && !isFetching) fetchNextPage()
}, [
dataIndex,
dataLen,
expenseCount,
expenses,
groupId,
hasMoreData,
inView,
isFetching,
])
const isLoading =
expensesAreLoading || !expenses || groupIsLoading || !groupData
useEffect(() => {
if (inView && hasMore && !isLoading) fetchNextPage()
}, [fetchNextPage, hasMore, inView, isLoading])
const groupedExpensesByDate = useMemo(
() => getGroupedExpensesByDate(expenses),
() => (expenses ? getGroupedExpensesByDate(expenses) : {}),
[expenses],
)
return expenses.length > 0 ? (
if (isLoading) return <ExpensesLoading />
if (expenses.length === 0)
return (
<p className="px-6 text-sm py-6">
{t('noExpenses')}{' '}
<Button variant="link" asChild className="-m-4">
<Link href={`/groups/${groupId}/expenses/create`}>
{t('createFirst')}
</Link>
</Button>
</p>
)
return (
<>
<SearchBar onValueChange={(value) => setSearchText(value)} />
{Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
let groupExpenses = groupedExpensesByDate[expenseGroup]
if (!groupExpenses) return null
groupExpenses = groupExpenses.filter(({ title }) =>
title.toLowerCase().includes(searchText.toLowerCase()),
)
if (groupExpenses.length === 0) return null
if (!groupExpenses || groupExpenses.length === 0) return null
return (
<div key={expenseGroup}>
@@ -152,44 +166,47 @@ 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]'
}
>
{expenseGroup}
{t(`Groups.${expenseGroup}`)}
</div>
{groupExpenses.map((expense) => (
<ExpenseCard
key={expense.id}
expense={expense}
currency={currency}
currency={groupData.group.currency}
groupId={groupId}
/>
))}
</div>
)
})}
{expenses.length < expenseCount &&
[0, 1, 2].map((i) => (
<div
key={i}
className="border-t flex justify-between items-center px-6 py-4 text-sm"
ref={i === 0 ? ref : undefined}
>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
</div>
<div>
<Skeleton className="h-4 w-16 rounded-full" />
</div>
</div>
))}
{hasMore && <ExpensesLoading ref={loadingRef} />}
</>
) : (
<p className="px-6 text-sm py-6">
Your group doesnt contain any expense yet.{' '}
<Button variant="link" asChild className="-m-4">
<Link href={`/groups/${groupId}/expenses/create`}>
Create the first one
</Link>
</Button>
</p>
)
}
const ExpensesLoading = forwardRef<HTMLDivElement>((_, ref) => {
return (
<div ref={ref}>
<Skeleton className="mx-4 sm:mx-6 mt-1 mb-2 h-3 w-32 rounded-full" />
{[0, 1, 2].map((i) => (
<div
key={i}
className="flex justify-between items-start px-2 sm:px-6 py-4 text-sm gap-2"
>
<div className="flex-0 pl-2 pr-1">
<Skeleton className="h-4 w-4 rounded-full" />
</div>
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
</div>
<div className="flex-0 flex flex-col gap-2 items-end mr-2 sm:mr-12">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-20 rounded-full" />
</div>
</div>
))}
</div>
)
})
ExpensesLoading.displayName = 'ExpensesLoading'

View File

@@ -0,0 +1,75 @@
'use client'
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button'
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
export const revalidate = 3600
export const metadata: Metadata = {
title: 'Expenses',
}
export default function GroupExpensesPageClient({
groupId,
enableReceiptExtract,
}: {
groupId: string
enableReceiptExtract: boolean
}) {
const t = useTranslations('Expenses')
return (
<>
<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">
<CardHeader className="flex-1 p-4 sm:p-6">
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
<Button variant="secondary" size="icon" asChild>
<Link
prefetch={false}
href={`/groups/${groupId}/expenses/export/json`}
target="_blank"
title={t('exportJson')}
>
<Download className="w-4 h-4" />
</Link>
</Button>
{enableReceiptExtract && (
<CreateFromReceiptButton groupId={groupId} />
)}
<Button asChild size="icon">
<Link
href={`/groups/${groupId}/expenses/create`}
title={t('create')}
>
<Plus className="w-4 h-4" />
</Link>
</Button>
</CardHeader>
</div>
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
<ExpenseList groupId={groupId} />
</CardContent>
</Card>
<ActiveUserModal groupId={groupId} />
</>
)
}

View File

@@ -1,27 +1,6 @@
import { cached } from '@/app/cached-functions'
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button'
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
getCategories,
getGroupExpenseCount,
getGroupExpenses,
} from '@/lib/api'
import GroupExpensesPageClient from '@/app/groups/[groupId]/expenses/page.client'
import { env } from '@/lib/env'
import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
export const revalidate = 3600
@@ -34,96 +13,10 @@ export default async function GroupExpensesPage({
}: {
params: { groupId: string }
}) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
const categories = await getCategories()
return (
<>
<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">
<CardHeader className="flex-1 p-4 sm:p-6">
<CardTitle>Expenses</CardTitle>
<CardDescription>
Here are the expenses that you created for your group.
</CardDescription>
</CardHeader>
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
<Button variant="secondary" size="icon" asChild>
<Link
prefetch={false}
href={`/groups/${groupId}/expenses/export/json`}
target="_blank"
title="Export to JSON"
>
<Download className="w-4 h-4" />
</Link>
</Button>
{env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT && (
<CreateFromReceiptButton
groupId={groupId}
groupCurrency={group.currency}
categories={categories}
/>
)}
<Button asChild size="icon">
<Link
href={`/groups/${groupId}/expenses/create`}
title="Create expense"
>
<Plus className="w-4 h-4" />
</Link>
</Button>
</CardHeader>
</div>
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
<Suspense
fallback={[0, 1, 2].map((i) => (
<div
key={i}
className="border-t flex justify-between items-center px-6 py-4 text-sm"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
</div>
<div>
<Skeleton className="h-4 w-16 rounded-full" />
</div>
</div>
))}
>
<Expenses group={group} />
</Suspense>
</CardContent>
</Card>
<ActiveUserModal group={group} />
</>
)
}
type Props = {
group: NonNullable<Awaited<ReturnType<typeof cached.getGroup>>>
}
async function Expenses({ group }: Props) {
const expenseCount = await getGroupExpenseCount(group.id)
const expenses = await getGroupExpenses(group.id, {
offset: 0,
length: 200,
})
return (
<ExpenseList
expensesFirstPage={expenses}
expenseCount={expenseCount}
groupId={group.id}
currency={group.currency}
participants={group.participants}
<GroupExpensesPageClient
groupId={groupId}
enableReceiptExtract={env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT}
/>
)
}

View File

@@ -0,0 +1,30 @@
'use client'
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
import { ShareButton } from '@/app/groups/[groupId]/share-button'
import { Skeleton } from '@/components/ui/skeleton'
import { trpc } from '@/trpc/client'
import Link from 'next/link'
export const GroupHeader = ({ groupId }: { groupId: string }) => {
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
return (
<div className="flex flex-col justify-between gap-3">
<h1 className="font-bold text-2xl">
<Link href={`/groups/${groupId}`}>
{isLoading || !data ? (
<Skeleton className="mt-1.5 mb-1.5 h-5 w-32" />
) : (
<div className="flex">{data.group.name}</div>
)}
</Link>
</h1>
<div className="flex gap-2 justify-between">
<GroupTabs groupId={groupId} />
{data?.group && <ShareButton group={data.group} />}
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,52 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { trpc } from '@/trpc/client'
import { Pencil } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
export default function GroupInformation({ groupId }: { groupId: string }) {
const t = useTranslations('Information')
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>{t('title')}</span>
<Button size="icon" asChild className="-mb-12">
<Link href={`/groups/${groupId}/edit`}>
<Pencil className="w-4 h-4" />
</Link>
</Button>
</CardTitle>
<CardDescription className="mr-12">
{t('description')}
</CardDescription>
</CardHeader>
<CardContent className="prose prose-sm sm:prose-base max-w-full whitespace-break-spaces">
{isLoading || !data ? (
<div className="py-1 flex flex-col gap-2">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
) : data.group.information ? (
<p className="text-foreground">{data.group.information}</p>
) : (
<p className="text-muted-foreground text-sm">{t('empty')}</p>
)}
</CardContent>
</Card>
</>
)
}

View File

@@ -0,0 +1,14 @@
import GroupInformation from '@/app/groups/[groupId]/information/group-information'
import { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Group Information',
}
export default function InformationPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
return <GroupInformation groupId={groupId} />
}

View File

@@ -1,11 +1,9 @@
import { cached } from '@/app/cached-functions'
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
import { GroupHeader } from '@/app/groups/[groupId]/group-header'
import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
import { ShareButton } from '@/app/groups/[groupId]/share-button'
import { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { PropsWithChildren, Suspense } from 'react'
import { PropsWithChildren } from 'react'
type Props = {
params: {
@@ -35,18 +33,7 @@ export default async function GroupLayout({
return (
<>
<div className="flex flex-col justify-between gap-3">
<h1 className="font-bold text-2xl">
<Link href={`/groups/${groupId}`}>{group.name}</Link>
</h1>
<div className="flex gap-2 justify-between">
<Suspense>
<GroupTabs groupId={groupId} />
</Suspense>
<ShareButton group={group} />
</div>
</div>
<GroupHeader groupId={groupId} />
{children}

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
import { Totals } from '@/app/groups/[groupId]/stats/totals'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { useTranslations } from 'next-intl'
export function TotalsPageClient({ groupId }: { groupId: string }) {
const t = useTranslations('Stats')
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>{t('Totals.title')}</CardTitle>
<CardDescription>{t('Totals.description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<Totals groupId={groupId} />
</CardContent>
</Card>
</>
)
}

View File

@@ -1,16 +1,5 @@
import { cached } from '@/app/cached-functions'
import { Totals } from '@/app/groups/[groupId]/stats/totals'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { getGroupExpenses } from '@/lib/api'
import { getTotalGroupSpending } from '@/lib/totals'
import { TotalsPageClient } from '@/app/groups/[groupId]/stats/page.client'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Totals',
@@ -21,29 +10,5 @@ export default async function TotalsPage({
}: {
params: { groupId: string }
}) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(groupId)
const totalGroupSpendings = getTotalGroupSpending(expenses)
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>Totals</CardTitle>
<CardDescription>
Spending summary of the entire group.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<Totals
group={group}
expenses={expenses}
totalGroupSpendings={totalGroupSpendings}
/>
</CardContent>
</Card>
</>
)
return <TotalsPageClient groupId={groupId} />
}

View File

@@ -1,4 +1,5 @@
import { formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
type Props = {
totalGroupSpendings: number
@@ -6,12 +7,14 @@ type 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 (
<div>
<div className="text-muted-foreground">Total group {balance}</div>
<div className="text-muted-foreground">{t(balance)}</div>
<div className="text-lg">
{formatCurrency(currency, Math.abs(totalGroupSpendings))}
{formatCurrency(currency, Math.abs(totalGroupSpendings), locale)}
</div>
</div>
)

View File

@@ -1,38 +1,27 @@
'use client'
import { getGroup, getGroupExpenses } from '@/lib/api'
import { getTotalActiveUserShare } from '@/lib/totals'
import { cn, formatCurrency } from '@/lib/utils'
import { useEffect, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
}
export function TotalsYourShare({ group, expenses }: Props) {
const [activeUser, setActiveUser] = useState('')
useEffect(() => {
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
if (activeUser) setActiveUser(activeUser)
}, [group, expenses])
const totalActiveUserShare =
activeUser === '' || activeUser === 'None'
? 0
: getTotalActiveUserShare(activeUser, expenses)
const currency = group.currency
export function TotalsYourShare({
totalParticipantShare = 0,
currency,
}: {
totalParticipantShare?: number
currency: string
}) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')
return (
<div>
<div className="text-muted-foreground">Your total share</div>
<div className="text-muted-foreground">{t('yourShare')}</div>
<div
className={cn(
'text-lg',
totalActiveUserShare < 0 ? 'text-green-600' : 'text-red-600',
totalParticipantShare < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalActiveUserShare))}
{formatCurrency(currency, Math.abs(totalParticipantShare), locale)}
</div>
</div>
)

View File

@@ -1,35 +1,31 @@
'use client'
import { getGroup, getGroupExpenses } from '@/lib/api'
import { useActiveUser } from '@/lib/hooks'
import { getTotalActiveUserPaidFor } from '@/lib/totals'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
}
export function TotalsYourSpendings({
totalParticipantSpendings = 0,
currency,
}: {
totalParticipantSpendings?: number
currency: string
}) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')
export function TotalsYourSpendings({ group, expenses }: Props) {
const activeUser = useActiveUser(group.id)
const totalYourSpendings =
activeUser === '' || activeUser === 'None'
? 0
: getTotalActiveUserPaidFor(activeUser, expenses)
const currency = group.currency
const balance = totalYourSpendings < 0 ? 'earnings' : 'spendings'
const balance =
totalParticipantSpendings < 0 ? 'yourEarnings' : 'yourSpendings'
return (
<div>
<div className="text-muted-foreground">Your total {balance}</div>
<div className="text-muted-foreground">{t(balance)}</div>
<div
className={cn(
'text-lg',
totalYourSpendings < 0 ? 'text-green-600' : 'text-red-600',
totalParticipantSpendings < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalYourSpendings))}
{formatCurrency(currency, Math.abs(totalParticipantSpendings), locale)}
</div>
</div>
)

View File

@@ -2,20 +2,36 @@
import { TotalsGroupSpending } from '@/app/groups/[groupId]/stats/totals-group-spending'
import { TotalsYourShare } from '@/app/groups/[groupId]/stats/totals-your-share'
import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-spending'
import { getGroup, getGroupExpenses } from '@/lib/api'
import { Skeleton } from '@/components/ui/skeleton'
import { useActiveUser } from '@/lib/hooks'
import { trpc } from '@/trpc/client'
export function Totals({
group,
expenses,
totalGroupSpendings,
}: {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
totalGroupSpendings: number
}) {
const activeUser = useActiveUser(group.id)
console.log('activeUser', activeUser)
export function Totals({ groupId }: { groupId: string }) {
const activeUser = useActiveUser(groupId)
const participantId =
activeUser && activeUser !== 'None' ? activeUser : undefined
const { data } = trpc.groups.stats.get.useQuery({ groupId, participantId })
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
if (!data || !groupData)
return (
<div className="flex flex-col gap-7">
{[0, 1, 2].map((index) => (
<div key={index}>
<Skeleton className="mt-1 h-3 w-48" />
<Skeleton className="mt-3 h-4 w-20" />
</div>
))}
</div>
)
const {
totalGroupSpendings,
totalParticipantShare,
totalParticipantSpendings,
} = data
const { group } = groupData
return (
<>
@@ -23,10 +39,16 @@ export function Totals({
totalGroupSpendings={totalGroupSpendings}
currency={group.currency}
/>
{activeUser && activeUser !== 'None' && (
{participantId && (
<>
<TotalsYourSpendings group={group} expenses={expenses} />
<TotalsYourShare group={group} expenses={expenses} />
<TotalsYourSpendings
totalParticipantSpendings={totalParticipantSpendings}
currency={group.currency}
/>
<TotalsYourShare
totalParticipantShare={totalParticipantShare}
currency={group.currency}
/>
</>
)}
</>

View File

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

View File

@@ -0,0 +1,21 @@
'use client'
import { GroupForm } from '@/components/group-form'
import { trpc } from '@/trpc/client'
import { useRouter } from 'next/navigation'
export const CreateGroup = () => {
const { mutateAsync } = trpc.groups.create.useMutation()
const utils = trpc.useUtils()
const router = useRouter()
return (
<GroupForm
onSubmit={async (groupFormValues) => {
const { groupId } = await mutateAsync({ groupFormValues })
await utils.groups.invalidate()
router.push(`/groups/${groupId}`)
}}
/>
)
}

View File

@@ -1,15 +1,10 @@
import { GroupForm } from '@/components/group-form'
import { createGroup } from '@/lib/api'
import { groupFormSchema } from '@/lib/schemas'
import { redirect } from 'next/navigation'
import { CreateGroup } from '@/app/groups/create/create-group'
import { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Create Group',
}
export default function CreateGroupPage() {
async function createGroupAction(values: unknown) {
'use server'
const groupFormValues = groupFormSchema.parse(values)
const group = await createGroup(groupFormValues)
redirect(`/groups/${group.id}`)
}
return <GroupForm onSubmit={createGroupAction} />
return <CreateGroup />
}

View File

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

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
'use client'
import { AddGroupByUrlButton } from '@/app/groups/add-group-by-url-button'
import { RecentGroupList } from '@/app/groups/recent-group-list'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
export function RecentGroupsPage() {
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="font-bold text-2xl flex-1">
<Link href="/groups">My groups</Link>
</h1>
<div className="flex gap-2">
<AddGroupByUrlButton reload={() => {}} />
<Button asChild>
<Link href="/groups/create">
{/* <Plus className="w-4 h-4 mr-2" /> */}
<>Create</>
</Link>
</Button>
</div>
</div>
<div>
<RecentGroupList />
</div>
</>
)
}

View File

@@ -1,11 +1,15 @@
import { ApplePwaSplash } from '@/app/apple-pwa-splash'
import { LocaleSwitcher } from '@/components/locale-switcher'
import { ProgressBar } from '@/components/progress-bar'
import { ThemeProvider } from '@/components/theme-provider'
import { ThemeToggle } from '@/components/theme-toggle'
import { Button } from '@/components/ui/button'
import { Toaster } from '@/components/ui/toaster'
import { env } from '@/lib/env'
import { TRPCProvider } from '@/trpc/client'
import type { Metadata, Viewport } from 'next'
import { NextIntlClientProvider, useTranslations } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server'
import Image from 'next/image'
import Link from 'next/link'
import { Suspense } from 'react'
@@ -59,93 +63,114 @@ export const viewport: Viewport = {
themeColor: '#047857',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
function Content({ children }: { children: React.ReactNode }) {
const t = useTranslations()
return (
<html lang="en" 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">
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
<TRPCProvider>
<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">
<Link
className="flex items-center gap-2 hover:scale-105 transition-transform"
href="/"
>
<Suspense>
<ProgressBar />
</Suspense>
<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">
<Link
className="flex items-center gap-2 hover:scale-105 transition-transform"
href="/"
>
<h1>
<Image
src="/logo-with-text.png"
className="m-1 h-auto w-auto"
width={(35 * 522) / 180}
height={35}
alt="Spliit"
/>
</h1>
<h1>
<Image
src="/logo-with-text.png"
className="m-1 h-auto w-auto"
width={(35 * 522) / 180}
height={35}
alt="Spliit"
/>
</h1>
</Link>
<div role="navigation" aria-label="Menu" className="flex">
<ul className="flex items-center text-sm">
<li>
<Button
variant="ghost"
size="sm"
asChild
className="-my-3 text-primary"
>
<Link href="/groups">{t('Header.groups')}</Link>
</Button>
</li>
<li>
<LocaleSwitcher />
</li>
<li>
<ThemeToggle />
</li>
</ul>
</div>
</header>
<div className="flex-1 flex flex-col">{children}</div>
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col sm:flex-row sm:justify-between gap-4 text-xs [&_a]:underline">
<div className="flex flex-col space-y-2">
<div className="sm:text-lg font-semibold text-base flex space-x-2 items-center">
<Link className="flex items-center gap-2" href="/">
<Image
src="/logo-with-text.png"
className="m-1 h-auto w-auto"
width={(35 * 522) / 180}
height={35}
alt="Spliit"
/>
</Link>
<div role="navigation" aria-label="Menu" className="flex">
<ul className="flex items-center text-sm">
<li>
<Button
variant="ghost"
asChild
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{' '}
</div>
<div className="flex flex-col space-y a--no-underline-text-white">
<span>{t('Footer.madeIn')}</span>
<span>
{t.rich('Footer.builtBy', {
author: (txt) => (
<a href="https://scastiel.dev" target="_blank" rel="noopener">
Sebastien Castiel
</a>{' '}
and{' '}
{txt}
</a>
),
source: (txt) => (
<a
href="https://github.com/spliit-app/spliit/graphs/contributors"
target="_blank"
rel="noopener"
>
contributors
{txt}
</a>
</span>
</div>
</div>
</footer>
<Toaster />
</ThemeProvider>
),
})}
</span>
</div>
</div>
</footer>
<Toaster />
</TRPCProvider>
)
}
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>
</html>
)

View File

@@ -1,31 +1,35 @@
import { Button } from '@/components/ui/button'
import { Github } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
// FIX for https://github.com/vercel/next.js/issues/58615
// export const dynamic = 'force-dynamic'
export default function HomePage() {
const t = useTranslations()
return (
<main>
<section className="py-16 md:py-24 lg:py-32">
<div className="container flex max-w-screen-md flex-col items-center gap-4 text-center">
<h1 className="!leading-none font-bold text-3xl sm:text-5xl md:text-6xl lg:text-7xl landing-header py-2">
Share <strong>Expenses</strong> <br /> with <strong>Friends</strong>{' '}
& <strong>Family</strong>
<h1 className="!leading-none font-bold text-2xl sm:text-3xl md:text-4xl lg:text-5xl landing-header py-2">
{t.rich('Homepage.title', {
strong: (chunks) => <strong>{chunks}</strong>,
})}
</h1>
<p className="max-w-[42rem] leading-normal text-muted-foreground sm:text-xl sm:leading-8">
Welcome to your new <strong>Spliit</strong> instance! <br />
Customize this page by editing <em>src/app/page.tsx</em>.
{t.rich('Homepage.description', {
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
<div className="flex gap-2">
<Button asChild>
<Link href="/groups">Go to groups</Link>
<Link href="/groups">{t('Homepage.button.groups')}</Link>
</Button>
<Button asChild variant="secondary">
<Link href="https://github.com/spliit-app/spliit">
<Github className="w-4 h-4 mr-2" />
GitHub
{t('Homepage.button.github')}
</Link>
</Button>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import {
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { useMessages } from "next-intl"
const Form = FormProvider
@@ -78,7 +79,7 @@ const FormItem = React.forwardRef<
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
<div ref={ref} className={cn("col-span-2 md:col-span-1 space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
@@ -144,8 +145,18 @@ const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const messages = useMessages()
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) {
return null

View File

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

27
src/i18n.ts Normal file
View File

@@ -0,0 +1,27 @@
import { getRequestConfig } from 'next-intl/server'
import { getUserLocale } from './lib/locale'
export const locales = [
'en-US',
'fi',
'fr-FR',
'es',
'de-DE',
'zh-CN',
'ru-RU',
'it-IT',
'ua-UA',
'ro',
] as const
export type Locale = (typeof locales)[number]
export type Locales = ReadonlyArray<Locale>
export const defaultLocale: Locale = 'en-US'
export default getRequestConfig(async () => {
const locale = await getUserLocale()
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
}
})

View File

@@ -12,6 +12,7 @@ export async function createGroup(groupFormValues: GroupFormValues) {
data: {
id: randomId(),
name: groupFormValues.name,
information: groupFormValues.information,
currency: groupFormValues.currency,
participants: {
createMany: {
@@ -226,6 +227,7 @@ export async function updateGroup(
where: { id: groupId },
data: {
name: groupFormValues.name,
information: groupFormValues.information,
currency: groupFormValues.currency,
participants: {
deleteMany: existingGroup.participants.filter(
@@ -265,7 +267,7 @@ export async function getCategories() {
export async function getGroupExpenses(
groupId: string,
options?: { offset: number; length: number },
options?: { offset?: number; length?: number; filter?: string },
) {
return prisma.expense.findMany({
select: {
@@ -285,7 +287,12 @@ export async function getGroupExpenses(
splitMode: true,
title: true,
},
where: { groupId },
where: {
groupId,
title: options?.filter
? { contains: options.filter, mode: 'insensitive' }
: undefined,
},
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
skip: options && options.offset,
take: options && options.length,
@@ -303,11 +310,34 @@ export async function getExpense(groupId: string, expenseId: string) {
})
}
export async function getActivities(groupId: string) {
return prisma.activity.findMany({
export async function getActivities(
groupId: string,
options?: { offset?: number; length?: number },
) {
const activities = await prisma.activity.findMany({
where: { groupId },
orderBy: [{ time: 'desc' }],
skip: options?.offset,
take: options?.length,
})
const expenseIds = activities
.map((activity) => activity.expenseId)
.filter(Boolean)
const expenses = await prisma.expense.findMany({
where: {
groupId,
id: { in: expenseIds },
},
})
return activities.map((activity) => ({
...activity,
expense:
activity.expenseId !== null
? expenses.find((expense) => expense.id === activity.expenseId)
: undefined,
}))
}
export async function logActivity(

View File

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

View File

@@ -52,12 +52,14 @@ export function useBaseUrl() {
/**
* @returns The active user, or `null` until it is fetched from local storage
*/
export function useActiveUser(groupId: string) {
export function useActiveUser(groupId?: string) {
const [activeUser, setActiveUser] = useState<string | null>(null)
useEffect(() => {
const activeUser = localStorage.getItem(`${groupId}-activeUser`)
if (activeUser) setActiveUser(activeUser)
if (groupId) {
const activeUser = localStorage.getItem(`${groupId}-activeUser`)
if (activeUser) setActiveUser(activeUser)
}
}, [groupId])
return activeUser

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

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

View File

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

71
src/lib/utils.test.ts Normal file
View File

@@ -0,0 +1,71 @@
import { formatCurrency } from './utils'
describe('formatCurrency', () => {
const currency = 'CUR'
/** For testing decimals */
const partialAmount = 1.23
/** For testing small full amounts */
const smallAmount = 1
/** For testing large full amounts */
const largeAmount = 10000
/** Non-breaking space */
const nbsp = '\xa0'
interface variation {
amount: number
locale: string
result: string
}
/**
* Variations to be tested, chosen as follows
* - `en-US` is a very common i18n fallback
* - `de-DE` exhibited faulty behavior in previous versions
*/
const variations: variation[] = [
{
amount: partialAmount,
locale: `en-US`,
result: `${currency}1.23`,
},
{
amount: smallAmount,
locale: `en-US`,
result: `${currency}1.00`,
},
{
amount: largeAmount,
locale: `en-US`,
result: `${currency}10,000.00`,
},
{
amount: partialAmount,
locale: `de-DE`,
result: `1,23${nbsp}${currency}`,
},
{
amount: smallAmount,
locale: `de-DE`,
result: `1,00${nbsp}${currency}`,
},
{
amount: largeAmount,
locale: `de-DE`,
result: `10.000,00${nbsp}${currency}`,
},
]
for (const variation of variations) {
it(`formats ${variation.amount} in ${variation.locale} without fractions`, () => {
expect(
formatCurrency(currency, variation.amount * 100, variation.locale),
).toBe(variation.result)
})
it(`formats ${variation.amount} in ${variation.locale} with fractions`, () => {
expect(
formatCurrency(currency, variation.amount, variation.locale, true),
).toBe(variation.result)
})
}
})

View File

@@ -15,9 +15,10 @@ export type DateTimeStyle = NonNullable<
>['dateStyle']
export function formatDate(
date: Date,
locale: string,
options: { dateStyle?: DateTimeStyle; timeStyle?: DateTimeStyle } = {},
) {
return date.toLocaleString('en-GB', {
return date.toLocaleString(locale, {
...options,
timeZone: 'UTC',
})
@@ -27,18 +28,31 @@ export function formatCategoryForAIPrompt(category: Category) {
return `"${category.grouping}/${category.name}" (ID: ${category.id})`
}
export function formatCurrency(currency: string, amount: number) {
const format = new Intl.NumberFormat('en-US', {
/**
* @param fractions Financial values in this app are generally processed in cents (or equivalent).
* They are are therefore integer representations of the amount (e.g. 100 for USD 1.00).
* Set this to `true` if you need to pass a value with decimal fractions instead (e.g. 1.00 for USD 1.00).
*/
export function formatCurrency(
currency: string,
amount: number,
locale: string,
fractions?: boolean,
) {
const format = new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: 'currency',
// '€' will be placed in correct position
currency: 'EUR',
})
const formattedAmount = format.format(amount / 100)
return `${currency} ${formattedAmount}`
const formattedAmount = format.format(fractions ? amount : amount / 100)
return formattedAmount.replace('€', currency)
}
export function formatFileSize(size: number) {
export function formatFileSize(size: number, locale: string) {
const formatNumber = (num: number) =>
num.toLocaleString('en-US', {
num.toLocaleString(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
})
@@ -48,3 +62,13 @@ export function formatFileSize(size: number) {
if (size > 1024) return `${formatNumber(size / 1024)} kB`
return `${formatNumber(size)} B`
}
export function normalizeString(input: string): string {
// Replaces special characters
// Input: áäåèéę
// Output: aaaeee
return input
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
}

60
src/trpc/client.tsx Normal file
View File

@@ -0,0 +1,60 @@
'use client' // <-- to make sure we can mount the Provider from a server component
import type { QueryClient } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { createTRPCReact } from '@trpc/react-query'
import { useState } from 'react'
import superjson from 'superjson'
import { makeQueryClient } from './query-client'
import type { AppRouter } from './routers/_app'
export const trpc = createTRPCReact<AppRouter>()
let clientQueryClientSingleton: QueryClient
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return makeQueryClient()
}
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= makeQueryClient())
}
function getUrl() {
const base = (() => {
if (typeof window !== 'undefined') return ''
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
return 'http://localhost:3000'
})()
return `${base}/api/trpc`
}
export function TRPCProvider(
props: Readonly<{
children: React.ReactNode
}>,
) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient()
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
transformer: superjson,
url: getUrl(),
}),
],
}),
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</trpc.Provider>
)
}

25
src/trpc/init.ts Normal file
View File

@@ -0,0 +1,25 @@
import { initTRPC } from '@trpc/server'
import { cache } from 'react'
import superjson from 'superjson'
export const createTRPCContext = cache(async () => {
/**
* @see: https://trpc.io/docs/server/context
*/
return {}
})
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create({
/**
* @see https://trpc.io/docs/server/data-transformers
*/
transformer: superjson,
})
// Base router and procedure helpers
export const createTRPCRouter = t.router
export const baseProcedure = t.procedure

21
src/trpc/query-client.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defaultShouldDehydrateQuery, QueryClient } from '@tanstack/react-query'
import superjson from 'superjson'
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: superjson.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
hydrate: {
deserializeData: superjson.deserialize,
},
},
})
}

12
src/trpc/routers/_app.ts Normal file
View File

@@ -0,0 +1,12 @@
import { categoriesRouter } from '@/trpc/routers/categories'
import { groupsRouter } from '@/trpc/routers/groups'
import { inferRouterOutputs } from '@trpc/server'
import { createTRPCRouter } from '../init'
export const appRouter = createTRPCRouter({
groups: groupsRouter,
categories: categoriesRouter,
})
export type AppRouter = typeof appRouter
export type AppRouterOutput = inferRouterOutputs<AppRouter>

View File

@@ -0,0 +1,6 @@
import { createTRPCRouter } from '@/trpc/init'
import { listCategoriesProcedure } from '@/trpc/routers/categories/list.procedure'
export const categoriesRouter = createTRPCRouter({
list: listCategoriesProcedure,
})

View File

@@ -0,0 +1,6 @@
import { getCategories } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
export const listCategoriesProcedure = baseProcedure.query(async () => {
return { categories: await getCategories() }
})

View File

@@ -0,0 +1,6 @@
import { createTRPCRouter } from '@/trpc/init'
import { listGroupActivitiesProcedure } from '@/trpc/routers/groups/activities/list.procedure'
export const activitiesRouter = createTRPCRouter({
list: listGroupActivitiesProcedure,
})

View File

@@ -0,0 +1,23 @@
import { getActivities } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const listGroupActivitiesProcedure = baseProcedure
.input(
z.object({
groupId: z.string(),
cursor: z.number().optional().default(0),
limit: z.number().optional().default(5),
}),
)
.query(async ({ input: { groupId, cursor, limit } }) => {
const activities = await getActivities(groupId, {
offset: cursor,
length: limit + 1,
})
return {
activities: activities.slice(0, limit),
hasMore: !!activities[limit],
nextCursor: cursor + limit,
}
})

View File

@@ -0,0 +1,6 @@
import { createTRPCRouter } from '@/trpc/init'
import { listGroupBalancesProcedure } from '@/trpc/routers/groups/balances/list.procedure'
export const groupBalancesRouter = createTRPCRouter({
list: listGroupBalancesProcedure,
})

View File

@@ -0,0 +1,19 @@
import { getGroupExpenses } from '@/lib/api'
import {
getBalances,
getPublicBalances,
getSuggestedReimbursements,
} from '@/lib/balances'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const listGroupBalancesProcedure = baseProcedure
.input(z.object({ groupId: z.string().min(1) }))
.query(async ({ input: { groupId } }) => {
const expenses = await getGroupExpenses(groupId)
const balances = getBalances(expenses)
const reimbursements = getSuggestedReimbursements(balances)
const publicBalances = getPublicBalances(reimbursements)
return { balances: publicBalances, reimbursements }
})

View File

@@ -0,0 +1,15 @@
import { createGroup } from '@/lib/api'
import { groupFormSchema } from '@/lib/schemas'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const createGroupProcedure = baseProcedure
.input(
z.object({
groupFormValues: groupFormSchema,
}),
)
.mutation(async ({ input: { groupFormValues } }) => {
const group = await createGroup(groupFormValues)
return { groupId: group.id }
})

View File

@@ -0,0 +1,23 @@
import { createExpense } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const createGroupExpenseProcedure = baseProcedure
.input(
z.object({
groupId: z.string().min(1),
expenseFormValues: expenseFormSchema,
participantId: z.string().optional(),
}),
)
.mutation(
async ({ input: { groupId, expenseFormValues, participantId } }) => {
const expense = await createExpense(
expenseFormValues,
groupId,
participantId,
)
return { expenseId: expense.id }
},
)

View File

@@ -0,0 +1,16 @@
import { deleteExpense } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const deleteGroupExpenseProcedure = baseProcedure
.input(
z.object({
expenseId: z.string().min(1),
groupId: z.string().min(1),
participantId: z.string().optional(),
}),
)
.mutation(async ({ input: { expenseId, groupId, participantId } }) => {
await deleteExpense(groupId, expenseId, participantId)
return {}
})

View File

@@ -0,0 +1,17 @@
import { getExpense } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
export const getGroupExpenseProcedure = baseProcedure
.input(z.object({ groupId: z.string().min(1), expenseId: z.string().min(1) }))
.query(async ({ input: { groupId, expenseId } }) => {
const expense = await getExpense(groupId, expenseId)
if (!expense) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Expense not found',
})
}
return { expense }
})

View File

@@ -0,0 +1,14 @@
import { createTRPCRouter } from '@/trpc/init'
import { createGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/create.procedure'
import { deleteGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/delete.procedure'
import { getGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/get.procedure'
import { listGroupExpensesProcedure } from '@/trpc/routers/groups/expenses/list.procedure'
import { updateGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/update.procedure'
export const groupExpensesRouter = createTRPCRouter({
list: listGroupExpensesProcedure,
get: getGroupExpenseProcedure,
create: createGroupExpenseProcedure,
update: updateGroupExpenseProcedure,
delete: deleteGroupExpenseProcedure,
})

View File

@@ -0,0 +1,29 @@
import { getGroupExpenses } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const listGroupExpensesProcedure = baseProcedure
.input(
z.object({
groupId: z.string().min(1),
cursor: z.number().optional(),
limit: z.number().optional(),
filter: z.string().optional(),
}),
)
.query(async ({ input: { groupId, cursor = 0, limit = 10, filter } }) => {
const expenses = await getGroupExpenses(groupId, {
offset: cursor,
length: limit + 1,
filter,
})
return {
expenses: expenses.slice(0, limit).map((expense) => ({
...expense,
createdAt: new Date(expense.createdAt),
expenseDate: new Date(expense.expenseDate),
})),
hasMore: !!expenses[limit],
nextCursor: cursor + limit,
}
})

View File

@@ -0,0 +1,27 @@
import { updateExpense } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const updateGroupExpenseProcedure = baseProcedure
.input(
z.object({
expenseId: z.string().min(1),
groupId: z.string().min(1),
expenseFormValues: expenseFormSchema,
participantId: z.string().optional(),
}),
)
.mutation(
async ({
input: { expenseId, groupId, expenseFormValues, participantId },
}) => {
const expense = await updateExpense(
groupId,
expenseId,
expenseFormValues,
participantId,
)
return { expenseId: expense.id }
},
)

View File

@@ -0,0 +1,19 @@
import { getGroup, getGroupExpensesParticipants } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
export const getGroupProcedure = baseProcedure
.input(z.object({ groupId: z.string().min(1) }))
.query(async ({ input: { groupId } }) => {
const group = await getGroup(groupId)
if (!group) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Group not found.',
})
}
const participantsWithExpenses = await getGroupExpensesParticipants(groupId)
return { group, participantsWithExpenses }
})

View File

@@ -0,0 +1,19 @@
import { createTRPCRouter } from '@/trpc/init'
import { activitiesRouter } from '@/trpc/routers/groups/activities'
import { groupBalancesRouter } from '@/trpc/routers/groups/balances'
import { createGroupProcedure } from '@/trpc/routers/groups/create.procedure'
import { groupExpensesRouter } from '@/trpc/routers/groups/expenses'
import { getGroupProcedure } from '@/trpc/routers/groups/get.procedure'
import { groupStatsRouter } from '@/trpc/routers/groups/stats'
import { updateGroupProcedure } from '@/trpc/routers/groups/update.procedure'
export const groupsRouter = createTRPCRouter({
expenses: groupExpensesRouter,
balances: groupBalancesRouter,
stats: groupStatsRouter,
activities: activitiesRouter,
get: getGroupProcedure,
create: createGroupProcedure,
update: updateGroupProcedure,
})

View File

@@ -0,0 +1,35 @@
import { getGroupExpenses } from '@/lib/api'
import {
getTotalActiveUserPaidFor,
getTotalActiveUserShare,
getTotalGroupSpending,
} from '@/lib/totals'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const getGroupStatsProcedure = baseProcedure
.input(
z.object({
groupId: z.string().min(1),
participantId: z.string().optional(),
}),
)
.query(async ({ input: { groupId, participantId } }) => {
const expenses = await getGroupExpenses(groupId)
const totalGroupSpendings = getTotalGroupSpending(expenses)
const totalParticipantSpendings =
participantId !== undefined
? getTotalActiveUserPaidFor(participantId, expenses)
: undefined
const totalParticipantShare =
participantId !== undefined
? getTotalActiveUserShare(participantId, expenses)
: undefined
return {
totalGroupSpendings,
totalParticipantSpendings,
totalParticipantShare,
}
})

Some files were not shown because too many files have changed in this diff Show More