142 Commits

Author SHA1 Message Date
Sebastien Castiel
0b72fd2000 Only Irish coffee provides in a single glass all four essential food groups --
alcohol, caffeine, sugar, and fat.
		-- Alex Levine
2024-10-25 15:45:32 -04:00
Sebastien Castiel
a7873fda02 A thing is not necessarily true because a man dies for it.
-- Oscar Wilde, "The Portrait of Mr. W.H."
2024-10-25 15:03:04 -04:00
Sebastien Castiel
c1c75fa260 Q: Why do mountain climbers rope themselves together?
A:	To prevent the sensible ones from going home.
2024-10-25 15:02:31 -04:00
Sebastien Castiel
9bdc8a715c Everyone wants results, but no one is willing to do what it takes to get them.
-- Dirty Harry
2024-10-25 14:37:17 -04:00
Sebastien Castiel
35bbb04b9d Test-tube babies shouldn't throw stones. 2024-10-25 14:28:32 -04:00
Sebastien Castiel
5d96cdc1c2 If at first you don't succeed, redefine success. 2024-10-25 14:20:24 -04:00
Sebastien Castiel
56b4010b91 Experience varies directly with equipment ruined. 2024-10-25 14:19:15 -04:00
Sebastien Castiel
62cfad1a32 Hindsight is always 20:20.
-- Billy Wilder
2024-10-25 14:08:53 -04:00
Sebastien Castiel
4db788680e Use tRPC for recent groups page (#253)
* Use tRPC for recent groups page

* Use tRPC for adding group by URL

* Use tRPC for saving visited group

* Group context
2024-10-20 17:50:52 -04:00
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
Sebastien Castiel
e990e00a75 Upgrade Next.js & React to latest versions (#159) 2024-05-29 22:25:52 -04:00
Stefan Hynst
0c05499107 Add support for group income (= negative expenses) (#158)
* Allow negative amount for expenses to be entered

- an expense becomes an income
- this does not affect calculations, i.e. an income can be split just like an expense

* Incomes should not be reimbursements

when entering a negative number
- deselect 'isReimbursement'
- hide reimbursement checkbox

* Change captions when entering a negative number

- "expense" becomes "income"
- "paid" becomes "received"

* Format incomes on expense list

- replace "paid by" with "received by"

* Format incomes on "Stats" tab

- a group's or participants balance might be negative
- in this case "spendings" will be "earnings" (display accordingly)
- always display positive numbers
- for active user: highlight spendings/earnings in red/green

* Fix typo

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:20:04 -04:00
Oliver Wong
3887efd9ee Use placeholder for new participant input (#153)
* use placeholder for new participant

* Fix formatting

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:11:24 -04:00
dcbr
e619c1a5b4 Add basic activity log (#141)
* Add basic activity log

* Add database migration

* Fix layout

* Fix types

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:06:45 -04:00
Lauri Vuorela
10e13d1f6b copy the next.config.js in order to get custom domains working again (#147) 2024-05-29 21:46:16 -04:00
Lauri Vuorela
f9d915378b change onClick to onFocus, with a slight delay for safari (#144)
* change onClick to onFocus, with a slight delay for safari

* typo

* fix variable name

* Fix style

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 21:45:46 -04:00
Antonin RAFFIN
74465c0565 Fix relative path docker db script (#154)
Without this, docker complained:
```
docker: Error response from daemon: create ./postgres-data: "./postgres-data" includes invalid characters for a local volume name, only "[a-zA-Z0-9][a-zA-Z0-9_.-]" are allowed. If you intended to pass a host directory, use absolute path.
```

Followed recommendation from https://stackoverflow.com/questions/46526165/docker-invalid-characters-for-volume-when-using-relative-paths
2024-05-29 21:38:37 -04:00
Stefan Hynst
d3fd8027a5 Implement "infinite scroll" for expenses (#95)
* Extract ExpenseCard vom ExpenseList

* Implement simple pagination of expenses (see #30)

- display only this year's entries by default
- a "Show more" button reveals all expenses

* Turn getPrisma() into constant "prisma"

- getPrisma() is not async and doesn't need to be awaited
- turn getPrisma() into exported constant "prisma"

* Select fields to be returned by getGroupExpenses()

- make JSON more concise and less redundant
- some properties were removed (i.e.instead of "expense.paidById" use "expense.paidBy.id")

* Remove "participants" from ExpenseCard

- no need to search for participant by id to get it's name
- name property is already present in expense

* Add option to fetch a slice of group expenses

- specify offset and length to get expenses for [offset, offset+length[
- add function to get total number of group expenses

* Add api route for client to fetch group expenses

* Remove "Show more" button from expense list

* Implement infinite scroll

- in server component Page
  - only load first 200 expenses max
  - pass preloaded expenses and total count

- in client component ExpenseList, if there are more expenses to show
  - test if there are more expenses
  - append preloading "skeletons" to end of list
  - fetch more expenses when last item in list comes into view
  - after each fetch increase fetch-length by factor 1.5
    - rationale: db fetch usually is not the issue here, the longer the list gets, the longer react needs to redraw

* Use server action instead of api endpoint

* Fixes

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 21:36:07 -04:00
Stefan Hynst
833237b613 Add "x" button to cancel search in search bar (#107) 2024-05-29 21:26:04 -04:00
dcbr
1cd2b273f9 Show the impact of an expense on the active user's balance (#139)
* Add devcontainer configuration for codespace support

* Show the impact of an expense on the active user's balance

* Run prettier

* Put the balance on a different line

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-04-13 13:07:18 -04:00
dcbr
1ad470309b Add devcontainer configuration for codespace support (#138) 2024-04-13 12:57:47 -04:00
Deep Golani
2fd38aadd9 Add notes in expense (#126)
* Feature: Added notes in expense

* Add missing notes in form values

* Prettier

---------

Co-authored-by: deep.golani <deep.golani@bfhl.in>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-04-05 08:38:38 -04:00
magomzr
b61d1836ea Add titles for a better user experience (#137)
Co-authored-by: Mario Gómez <60667991+mgomezarr@users.noreply.github.com>
2024-04-05 08:29:08 -04:00
Sahil Mehra
c3903849ec Bug: Fixed wrong paid by Name in Reimbursement (#134) 2024-04-02 08:20:56 -04:00
Jan T
b67a0be0dd Add "save as default splitting options" feature (#120)
* Add "save as default splitting options" feature

* Fix type issue

* Run autoformatter
2024-03-09 11:45:53 -05:00
Guhan
e07d237218 Ask for confirmation to delete an expense (#124)
* feat: added a popup asking for confirmation to delete an expense

* fix: changed cancel option as a button and formatting issues

* fix: removed unnecessary tags and replaced generic tags with proper components

* Small fix to avoid warning in console

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-03-09 11:38:30 -05:00
annalouisep
cc37083389 Expense list: add section for planned purchases (#122)
* add planned purchases

* Updating verbiage to reflect possible future entry types
2024-03-09 11:30:24 -05:00
Sebastien Castiel
552953151a Don’t count reimbursements in stats (fixes #118) (#119) 2024-02-29 10:21:23 -05:00
Jan T
b227401dd6 Minor: reorder Dockerfile layers for better cache use (#116) 2024-02-28 10:59:19 -05:00
sashkent3
6a5efc5f3f Fix the default value for the expense shares field (#113)
* fix default shares value

* fix default shares value for reimbursements

* prettier
2024-02-28 10:58:49 -05:00
Jan T
4c5f8a6aa5 Fix decimal separator issue in numeric form fields (#115)
* Revert 5b65b8f, fix comma issue with type="text" and onChange

* Fix comma issue in "paid for" input

* Run prettier autoformat

* Allow only digits and dots in currency inputs

* Fix behaviour in paidFor field

* Fix duplicated onChange prop

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-02-28 10:57:55 -05:00
Lauri Vuorela
c2b591349b add a prettier script for ease of use (#105) 2024-02-28 10:45:02 -05:00
Lauri Vuorela
56c1865264 Add onClick-event to select all to amount input (#104)
* add onfocus-event to select all to amount input

* use onClick instead of onFocus
2024-02-28 10:44:27 -05:00
Anurag
2f991e680b feat: initialise a new totals tab with basic UI (#94)
* feat: initialise a new totals tab with basic UI

* fix: update group tabs and add stats page

* fix: styling within the new elements

* Prettier

* Display active user expenses only if active user is set

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-02-28 10:43:25 -05:00
Jan T
2af0660383 Optimize docker image size (#91)
* Move prisma to runtime dependencies

* Optimize Dockerfile and build script

* Fix: remove mention of generated next-env.d.ts in Dockerfile

* Add missing reset.d.ts file to Dockerfile

* Remove compression steps from Dockerfile and entrypoint script

* Add an env file with mocked env vars added for Docker production builds

* Use server actions to get runtime env vars

* Refactor types and names

* Rollback serverActions, use parsed Zod object for runtime env

* Reintroduce featureFlags object to avoid passing secret envs to the frontend

* Improve string to boolean coercion

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>

* Run prettier autoformat

* Fix type issue, rename function to match behaviour better

---------

Co-authored-by: Lauri Vuorela <lauri.vuorela@gmail.com>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-02-14 10:18:30 -05:00
Sebastien Castiel
50525ad881 Add cancel button on expense form (fixes #92) 2024-02-14 09:49:06 -05:00
Lauri Vuorela
f7a13a0436 Round totals rather than expense by expense (#88)
* do balance rounding only on full balances rather than on every expense

* use "public balances" calculated from reimbursements to show on balance page

* fixes for totals that did not work as expected

* prettier
2024-02-13 14:35:57 -05:00
Jan T
5b65b8f049 Replace commas with dots in expense form schema amount field (#90) 2024-02-13 14:26:45 -05:00
Sebastien Castiel
0e6a2bdc6c Limit file upload size on the client (#84) 2024-02-06 10:19:57 -05:00
Lauri Vuorela
be0964d9e1 format currency with thousand separators (#81) 2024-02-04 20:16:30 -05:00
Mert Demir
fb49fb596a Automatic category from expense title (#80)
* environment variable

* random category draft

* get category from ai

* input limit and documentation

* use watch

* use field.name

* prettier

* presigned upload, readme warning, category to string util

* prettier

* check whether feature is enabled

* use process.env

* improved prompt to return id only

* remove console.debug

* show loader

* share class name

* prettier

* use template literals

* rename format util

* prettier
2024-02-04 12:23:11 -05:00
Sebastien Castiel
10fd69404a Add splash screen for iOS PWA 2024-02-04 11:17:53 -05:00
Raymond Berger
6dd631b03a Update start_url to /groups page (#77) 2024-02-03 10:30:27 -05:00
Mert Demir
08d75fd75c Support for additional S3 providers (#71)
* support for other s3 providers

* remove redundant route options

* use type safe env

* prettier
2024-01-31 17:00:19 -05:00
Sebastien Castiel
e6467b41fc Improve receipt scanning 2024-01-30 20:07:46 -05:00
Sebastien Castiel
4a9bf575bd Create expense from receipt (#69)
* Create expense from receipt

* Add modal

* Update README
2024-01-30 16:36:29 -05:00
Sebastien Castiel
9e300e0ff0 Use React’s cache to avoid some queries to the database 2024-01-30 12:57:21 -05:00
Sebastien Castiel
3847a67a19 Sort expenses by expense date, then by creation date (partial workaround for #67) 2024-01-29 15:14:51 -05:00
Sebastien Castiel
7695ffd62d Fix uploaded image names 2024-01-29 10:39:49 -05:00
Sebastien Castiel
091cd02c06 Use carousel to display images (fix dimensions) 2024-01-28 23:41:18 -05:00
Sebastien Castiel
9876d7045f Use carousel to display images 2024-01-28 23:28:44 -05:00
Lauri Vuorela
9759f61e0e Production target for Dockerfile (#57)
* add production build

* add back updates and use slim image

* udpate command

* ignore scripts

* add workdir

* fix workdirs

* docker image improvements

* use .example instead

* use dummy data instead

* remove unused env var and add comment

* fix entrypoints

* change name of script and add possibility for different commands

* change to safer default for volume

* add instructions for the dev docker container

* update copy

* add empty lines under topics to keep uniformity

* most RUN's in a single command

* add comment about volumes for dev target

* remove dev workflow

* remove dev workflow from readme

* Prettify README

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-28 19:30:26 -05:00
Sebastien Castiel
d43e731fe1 Attach documents to expenses (#64)
* Upload documents to receipts

* Improve documents

* Make the feature opt-in

* Fix file name issue
2024-01-28 18:51:29 -05:00
Sebastien Castiel
11d2e298e8 Improve recent groups design 2024-01-26 16:11:51 -05:00
Sebastien Castiel
0647000a77 Disable prefetch on export link 2024-01-26 15:50:32 -05:00
Vid Čufar
2228415323 Fix search functionality (#62)
* Improve README instructions for local setup

* Fix search functionality #61
- use 'includes' for expense filtering

* Ensure expense groups with no matching expenses are hidden after filtering

* Improve README instructions for local setup
2024-01-26 10:27:34 -05:00
Mert Demir
58ee685e22 paid for all, split evenly (#59) 2024-01-26 10:26:58 -05:00
Sebastien Castiel
545cf75e99 Join group by URL (Closes #55) 2024-01-24 11:12:55 -05:00
Sebastien Castiel
7956156d70 Upgrade Next.js to 14.1.0 2024-01-24 09:50:37 -05:00
Sebastien Castiel
2f58e466da Fix image size to prevent warning in the console 2024-01-23 22:27:07 -05:00
Sebastien Castiel
89ee5ae247 Add date and bring back group name in exported filename (#54) 2024-01-23 16:41:07 -05:00
Sebastien Castiel
1bd3f99d38 Fix export file name (Fix #54) 2024-01-22 13:05:35 -05:00
Sebastien Castiel
e32a12ce41 Update FUNDING.yml 2024-01-19 23:39:01 -05:00
Sebastien Castiel
49218e8e9d Update README.md 2024-01-19 23:37:12 -05:00
Sebastien Castiel
23eedcb619 Update FUNDING.yml 2024-01-19 23:33:28 -05:00
Sebastien Castiel
ba4107e440 Update README.md 2024-01-19 16:43:31 -05:00
Ankit Bahl
ae7cb2ccc8 Added search bar for expense list page (#52)
* Added search bar for expense list page

* Change search input styling

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-19 16:38:18 -05:00
Sebastien Castiel
3735509fea Update GitHub repository URL 2024-01-19 16:13:02 -05:00
Sebastien Castiel
1b1ebf015e Bring back NEXT_PUBLIC_BASE_URL (continued) 2024-01-19 12:49:22 -05:00
Sebastien Castiel
c138afadb9 Bring back NEXT_PUBLIC_BASE_URL 2024-01-19 12:43:17 -05:00
Sebastien Castiel
18ac2142a8 Update README.md 2024-01-19 12:10:19 -05:00
Sebastien Castiel
875b9787d0 Add deploy with Vercel button 2024-01-19 12:04:48 -05:00
Sebastien Castiel
4d86c8c727 Remove varianle NEXT_PUBLIC_BASE_URL 2024-01-19 12:03:16 -05:00
Sebastien Castiel
23524cb943 Clean project from marketing content (#50)
* Clean project from marketing content

* Remove some dependencies
2024-01-19 11:28:25 -05:00
Sebastien Castiel
f9040f8bed Merge feedback and support dialogs 2024-01-18 15:48:45 -05:00
Sebastien Castiel
395c86666c Fix mobile keyboard on shares field (Fix #49) 2024-01-18 09:07:47 -05:00
Sebastien Castiel
2728f24989 Remove unused code 2024-01-18 09:02:53 -05:00
Sebastien Castiel
314eba284b Responsive category selector with drawer 2024-01-17 12:30:56 -05:00
Sebastien Castiel
92156b29cb Use combobox for category selector 2024-01-17 12:07:03 -05:00
Sebastien Castiel
c4de3f605c Improve UI of expense list 2024-01-17 10:22:49 -05:00
Brandon Eng
ff6b84ff88 Group expenses (#48)
* Group expenses my date

* Group expenses my date

* typescript errors

* prettier

* getExpenseGroup

* update logic to use dayjs

* clean up
2024-01-17 09:42:00 -05:00
Sebastien Castiel
6b6d58e95e Add GitHub actions 2024-01-16 13:55:34 -05:00
Sebastien Castiel
d809e10d19 Update contributors 2024-01-16 10:43:04 -05:00
Brandon Eng
36cc4f1ef7 Ability to archive groups when they’re settled up (#45)
* Settled up icon on group card

* remove logs

* archived groups

* remove settled up

* remove more settled up

* recent-group-list-card

* sortGroups

* archiveGroup

* unarchiveGroup

* clean up

* more clean up

* Prettier, fix TS errors, add titles

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-16 10:41:46 -05:00
Sebastien Castiel
1141501edb Create FUNDING.yml 2024-01-15 18:24:48 -05:00
Sebastien Castiel
28902ad0ea Export group expenses as JSON (Closes #42) 2024-01-15 11:44:49 -05:00
Sebastien Castiel
8abdcb7d6f Fix donation modal with dark mode (Closes #46) 2024-01-15 09:19:53 -05:00
Sebastien Castiel
43f7ca700b Fix client-side error when editing date with keyboard (Closes #43) 2024-01-14 12:47:52 -05:00
Sebastien Castiel
beae336666 Add donation button (closes #40) 2024-01-14 11:43:48 -05:00
Sebastien Castiel
2dcb80f954 Update home page & README 2024-01-11 17:32:52 -05:00
Sebastien Castiel
c7fb810f80 Add category icons 2024-01-11 17:12:21 -05:00
Chris Johnston
45ee9cdba4 Assign categories to expenses (#28)
* add expense categories

* set category to Payment for reimbursements

* Insert categories as part of the migration

* Display category groups

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-11 16:38:30 -05:00
Sebastien Castiel
057f3e9c53 Update contributors 2024-01-11 15:30:50 -05:00
Max
76427c9f13 Docker container version (#39)
* + Dockerfile and compose file
+ Scripts dir and startup script
+ Build image npm script

* * Moves env to file

* + Tags image with info from package.json
* Moves image creation to script
* Updates README

* Update README.md

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>

---------

Co-authored-by: Maxime Jacob <mjacob-no-reply@proton.me>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-11 15:25:08 -05:00
Sebastien Castiel
ddce4d0bdb Add contributors link 2024-01-11 11:44:48 -05:00
Sebastien Castiel
cf41048aea Remove group (Fix #38) 2024-01-11 10:12:58 -05:00
Sebastien Castiel
f20ebd5bdd Improve design for expense list 2024-01-10 08:21:12 -05:00
Sebastien Castiel
9c728530c9 Fix font size in inputs 2024-01-09 15:38:08 -05:00
Sebastien Castiel
323b0ea128 Feedback button 2024-01-09 15:32:19 -05:00
Sebastien Castiel
5ce96aef30 Add contributors on home page 2024-01-09 11:25:42 -05:00
Sebastien Castiel
a258e85fae Update README.md 2024-01-09 09:15:38 -05:00
Sebastien Castiel
1b9e624004 Ask the user who they are when opening a group for the first time (#7) 2024-01-09 08:53:51 -05:00
Ankit Bahl
6bd3299331 Add activeUser for default payer per group (#16)
* Add activeUser for default payer per group

* Prettier, change labels, use useEffect

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-09 08:08:17 -05:00
Sebastien Castiel
a942369193 Fix how dates are displayed (Fix #33) 2024-01-09 07:45:28 -05:00
Chris Johnston
e891d259a5 add shares to paidFor in reimbursement (#32) 2024-01-08 16:22:30 -05:00
Sebastien Castiel
d9aeb45c83 Update README.md 2024-01-08 16:21:46 -05:00
Chris Johnston
76befff481 Fix UI bug when clicking reimbursement link (#31) 2024-01-08 15:56:54 -05:00
Sebastien Castiel
55883ce414 Mark a group as favorite (fixes #29) 2024-01-08 15:49:07 -05:00
Chris Johnston
bec1dd270a Add Expense Date (#26)
* add expense date

* Improve date formatting

* Prettier

* Change field description

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-08 14:26:44 -05:00
Sebastien Castiel
4566900f9c Fix items alignment 2024-01-08 12:29:20 -05:00
Sebastien Castiel
0a8e56f800 Add splitmode and shares to expenses (#11)
* Add splitmode and shares to expenses

* Update balances based on shares

* Change field size

* Form validation

* Redesign expense form

* Split unevenly by amount
2024-01-08 12:11:11 -05:00
122 changed files with 2505 additions and 6927 deletions

View File

@@ -0,0 +1,42 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node-postgres
{
"name": "spliit",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {
// "ghcr.io/frntn/devcontainers-features/prism:1": {}
// },
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "cp container.env.example .env && npm install",
"postAttachCommand": {
"npm": "npm run dev"
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// This can be used to network with other containers or with the host.
"forwardPorts": [3000, 5432],
"portsAttributes": {
"3000": {
"label": "App"
},
"5432": {
"label": "PostgreSQL"
}
},
// Configure tool-specific properties.
"customizations": {
"codespaces": {
"openFiles": [
"README.md"
]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View File

@@ -0,0 +1,33 @@
version: '3.8'
services:
app:
image: mcr.microsoft.com/devcontainers/typescript-node:latest
volumes:
- ../..:/workspaces:cached
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
network_mode: service:db
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
db:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: 1234
POSTGRES_USER: postgres
POSTGRES_DB: postgres
# Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
volumes:
postgres-data:

View File

@@ -1,3 +1,2 @@
POSTGRES_PRISMA_URL=postgresql://postgres:1234@localhost
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
NEXT_PUBLIC_BASE_URL=http://localhost:3000
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost

13
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: ['scastiel']
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: ['https://donate.stripe.com/28o3eh96G7hH8k89Ba']

36
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: CI
on:
push:
branches: ['main']
pull_request:
branches: ['main']
jobs:
checks:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Generate Prisma client
run: npx prisma generate
- name: Check TypeScript types
run: npm run check-types
- name: Check ESLint
run: npm run lint
- name: Check Prettier formatting
run: npm run check-formatting

5
.gitignore vendored
View File

@@ -27,7 +27,8 @@ yarn-error.log*
# local env files
.env*.local
.env
*.env
!scripts/build.env
# vercel
.vercel
@@ -38,3 +39,5 @@ next-env.d.ts
# db
postgres-data
/dist

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
src/components/ui

View File

@@ -1,6 +1,8 @@
[<img alt="Spliit" height="60" src="https://github.com/scastiel/spliit2/blob/main/public/logo-with-text.png?raw=true" />](https://spliit.app)
[<img alt="Spliit" height="60" src="https://github.com/spliit-app/spliit/blob/main/public/logo-with-text.png?raw=true" />](https://spliit.app)
Spliit is a free and open source alternative to Splitwise. I created it back in 2022 as a side project to learn the Go language, but rewrote it with Next.js since.
Spliit is a free and open source alternative to Splitwise. You can either use the official instance at [Spliit.app](https://spliit.app), or deploy your own instance:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fspliit-app%2Fspliit&project-name=my-spliit-instance&repository-name=my-spliit-instance&stores=%5B%7B%22type%22%3A%22postgres%22%7D%5D&)
## Features
@@ -10,12 +12,18 @@ Spliit is a free and open source alternative to Splitwise. I created it back in
- [x] Create reimbursement expenses
- [x] Progressive Web App
- [x] Select all/no participant for expenses
- [x] Split expenses unevenly [(#6)](https://github.com/spliit-app/spliit/issues/6)
- [x] Mark a group as favorite [(#29)](https://github.com/spliit-app/spliit/issues/29)
- [x] Tell the application who you are when opening a group [(#7)](https://github.com/spliit-app/spliit/issues/7)
- [x] Assign a category to expenses [(#35)](https://github.com/spliit-app/spliit/issues/35)
- [x] Search for expenses in a group [(#51)](https://github.com/spliit-app/spliit/issues/51)
- [x] Upload and attach images to expenses [(#63)](https://github.com/spliit-app/spliit/issues/63)
- [x] Create expense by scanning a receipt [(#23)](https://github.com/spliit-app/spliit/issues/23)
### Possible incoming features
- [ ] Tell the application who you are when opening a group [(#7)](https://github.com/scastiel/spliit2/issues/7)
- [ ] Ability to create recurring expenses [(#5)](https://github.com/scastiel/spliit2/issues/5)
- [ ] Ability to split expenses unevenly [(#6)](https://github.com/scastiel/spliit2/issues/6)
- [ ] Ability to create recurring expenses [(#5)](https://github.com/spliit-app/spliit/issues/5)
- [ ] Import expenses from Splitwise [(#22)](https://github.com/spliit-app/spliit/issues/22)
## Stack
@@ -29,13 +37,72 @@ Spliit is a free and open source alternative to Splitwise. I created it back in
The project is open to contributions. Feel free to open an issue or even a pull-request!
If you want to contribute financially and help us keep the application free and without ads, you can also:
- 💜 [Sponsor me (Sebastien)](https://github.com/sponsors/scastiel), or
- 💙 [Make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba).
## Run locally
1. Clone the repository (or fork it if you intend to contribute)
2. `npm install`
3. Start a PostgreSQL server. You can run `./start-local-db.sh` if you dont have a server already.
4. Copy the file `.env.example` as `.env`
5. `npm run dev`
2. Start a PostgreSQL server. You can run `./scripts/start-local-db.sh` if you dont have a server already.
3. Copy the file `.env.example` as `.env`
4. Run `npm install` to install dependencies. This will also apply database migrations and update Prisma Client.
5. Run `npm run dev` to start the development server
## Run in a container
1. Run `npm run build-image` to build the docker image from the Dockerfile
2. Copy the file `container.env.example` as `container.env`
3. Run `npm run start-container` to start the postgres and the spliit2 containers
4. You can access the app by browsing to http://localhost:3000
## Opt-in features
### Expense documents
Spliit offers users to upload images (to an AWS S3 bucket) and attach them to expenses. To enable this feature:
- Follow the instructions in the _S3 bucket_ and _IAM user_ sections of [next-s3-upload](https://next-s3-upload.codingvalue.com/setup#s3-bucket) to create and set up an S3 bucket where images will be stored.
- Update your environments variables with appropriate values:
```.env
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=true
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
S3_UPLOAD_BUCKET=name-of-s3-bucket
S3_UPLOAD_REGION=us-east-1
```
You can also use other S3 providers by providing a custom endpoint:
```.env
S3_UPLOAD_ENDPOINT=http://localhost:9000
```
### Create expense from receipt
You can offer users to create expense by uploading a receipt. This feature relies on [OpenAI GPT-4 with Vision](https://platform.openai.com/docs/guides/vision) and a public S3 storage endpoint.
To enable the feature:
- You must enable expense documents feature as well (see section above). That might change in the future, but for now we need to store images to make receipt scanning work.
- Subscribe to OpenAI API and get access to GPT 4 with Vision (you might need to buy credits in advance).
- Update your environment variables with appropriate values:
```.env
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=true
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
### Deduce category from title
You can offer users to automatically deduce the expense category from the title. Since this feature relies on a OpenAI subscription, follow the signup instructions above and configure the following environment variables:
```.env
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT=true
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
## License

View File

@@ -1,16 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

11
eslint.config.mjs Normal file
View File

@@ -0,0 +1,11 @@
import pluginJs from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default [
{ files: ['**/*.{js,mjs,cjs,ts}'] },
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
]

View File

@@ -1,5 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
const { withPlausibleProxy } = require('next-plausible')
module.exports = withPlausibleProxy()(nextConfig)

4671
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +1,53 @@
{
"name": "spliit2",
"name": "spliit-api",
"version": "0.1.0",
"private": true,
"main": "src/index.ts",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma migrate deploy && prisma generate"
"lint": "eslint",
"check-types": "tsc --noEmit",
"check-formatting": "prettier -c src",
"prettier": "prettier -w src",
"postinstall": "prisma generate"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@hookform/resolvers": "^3.3.2",
"@prisma/client": "5.6.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@prisma/client": "^5.6.0",
"@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",
"clsx": "^2.0.0",
"lucide-react": "^0.290.0",
"content-disposition": "^0.5.4",
"dayjs": "^1.11.10",
"nanoid": "^5.0.4",
"next": "^14.0.4",
"next-plausible": "^3.12.0",
"next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1",
"negotiator": "^0.6.3",
"openai": "^4.25.0",
"pg": "^8.11.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.47.0",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"prisma": "^5.7.0",
"sharp": "^0.33.2",
"superjson": "^2.2.1",
"ts-pattern": "^5.0.6",
"uuid": "^9.0.1",
"zod": "^3.22.4"
"vaul": "^0.8.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@total-typescript/ts-reset": "^0.5.1",
"@types/content-disposition": "^0.5.8",
"@types/negotiator": "^0.6.3",
"@types/node": "^20",
"@types/pg": "^8.10.9",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/uuid": "^9.0.6",
"autoprefixer": "^10",
"dotenv": "^16.3.1",
"eslint": "^8",
"eslint-config-next": "^14.0.4",
"postcss": "^8",
"eslint": "^8.57.1",
"globals": "^15.11.0",
"prettier": "^3.0.3",
"prettier-plugin-organize-imports": "^3.2.3",
"prisma": "^5.7.0",
"tailwindcss": "^3",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"typescript-eslint": "^8.11.0"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ExpensePaidFor" ADD COLUMN "shares" INTEGER NOT NULL DEFAULT 1;

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "SplitMode" AS ENUM ('EVENLY', 'BY_SHARES', 'BY_PERCENTAGE', 'BY_AMOUNT');
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "splitMode" "SplitMode" NOT NULL DEFAULT 'EVENLY';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "expenseDate" DATE NOT NULL DEFAULT CURRENT_DATE;

View File

@@ -0,0 +1,65 @@
/*
Warnings:
- Added the required column `categoryId` to the `Expense` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "categoryId" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "Category" (
"id" SERIAL NOT NULL,
"grouping" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- Insert categories
INSERT INTO "Category" ("id", "grouping", "name") VALUES (0, 'Uncategorized', 'General');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (1, 'Uncategorized', 'Payment');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (2, 'Entertainment', 'Entertainment');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (3, 'Entertainment', 'Games');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (4, 'Entertainment', 'Movies');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (5, 'Entertainment', 'Music');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (6, 'Entertainment', 'Sports');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (7, 'Food and Drink', 'Food and Drink');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (8, 'Food and Drink', 'Dining Out');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (9, 'Food and Drink', 'Groceries');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (10, 'Food and Drink', 'Liquor');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (11, 'Home', 'Home');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (12, 'Home', 'Electronics');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (13, 'Home', 'Furniture');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (14, 'Home', 'Household Supplies');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (15, 'Home', 'Maintenance');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (16, 'Home', 'Mortgage');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (17, 'Home', 'Pets');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (18, 'Home', 'Rent');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (19, 'Home', 'Services');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (20, 'Life', 'Childcare');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (21, 'Life', 'Clothing');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (22, 'Life', 'Education');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (23, 'Life', 'Gifts');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (24, 'Life', 'Insurance');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (25, 'Life', 'Medical Expenses');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (26, 'Life', 'Taxes');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (27, 'Transportation', 'Transportation');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (28, 'Transportation', 'Bicycle');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (29, 'Transportation', 'Bus/Train');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (30, 'Transportation', 'Car');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (31, 'Transportation', 'Gas/Fuel');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (32, 'Transportation', 'Hotel');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (33, 'Transportation', 'Parking');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (34, 'Transportation', 'Plane');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (35, 'Transportation', 'Taxi');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (36, 'Utilities', 'Utilities');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (37, 'Utilities', 'Cleaning');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (38, 'Utilities', 'Electricity');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (39, 'Utilities', 'Heat/Gas');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (40, 'Utilities', 'Trash');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (41, 'Utilities', 'TV/Phone/Internet');
INSERT INTO "Category" ("id", "grouping", "name") VALUES (42, 'Utilities', 'Water');
-- AddForeignKey
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "documentUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- The `documentUrls` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "Expense" DROP COLUMN "documentUrls",
ADD COLUMN "documentUrls" JSONB[] DEFAULT ARRAY[]::JSONB[];

View File

@@ -0,0 +1,20 @@
/*
Warnings:
- You are about to drop the column `documentUrls` on the `Expense` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Expense" DROP COLUMN "documentUrls";
-- CreateTable
CREATE TABLE "ExpenseDocument" (
"id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"expenseId" TEXT,
CONSTRAINT "ExpenseDocument_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ExpenseDocument" ADD CONSTRAINT "ExpenseDocument_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "Expense"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- Added the required column `height` to the `ExpenseDocument` table without a default value. This is not possible if the table is not empty.
- Added the required column `width` to the `ExpenseDocument` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "ExpenseDocument" ADD COLUMN "height" INTEGER NOT NULL,
ADD COLUMN "width" INTEGER NOT NULL;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "notes" TEXT;

View File

@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "ActivityType" AS ENUM ('UPDATE_GROUP', 'CREATE_EXPENSE', 'UPDATE_EXPENSE', 'DELETE_EXPENSE');
-- CreateTable
CREATE TABLE "Activity" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"activityType" "ActivityType" NOT NULL,
"participantId" TEXT,
"expenseId" TEXT,
"data" TEXT,
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

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

View File

@@ -14,9 +14,11 @@ datasource db {
model Group {
id String @id
name String
information String? @db.Text
currency String @default("$")
participants Participant[]
expenses Expense[]
activities Activity[]
createdAt DateTime @default(now())
}
@@ -29,17 +31,46 @@ model Participant {
expensesPaidFor ExpensePaidFor[]
}
model Category {
id Int @id @default(autoincrement())
grouping String
name String
Expense Expense[]
}
model Expense {
id String @id
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
id String @id
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
title String
category Category? @relation(fields: [categoryId], references: [id])
categoryId Int @default(0)
amount Int
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
paidById String
paidFor ExpensePaidFor[]
groupId String
isReimbursement Boolean @default(false)
createdAt DateTime @default(now())
isReimbursement Boolean @default(false)
splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now())
documents ExpenseDocument[]
notes String?
}
model ExpenseDocument {
id String @id
url String
width Int
height Int
Expense Expense? @relation(fields: [expenseId], references: [id])
expenseId String?
}
enum SplitMode {
EVENLY
BY_SHARES
BY_PERCENTAGE
BY_AMOUNT
}
model ExpensePaidFor {
@@ -47,6 +78,25 @@ model ExpensePaidFor {
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
expenseId String
participantId String
shares Int @default(1)
@@id([expenseId, participantId])
}
model Activity {
id String @id
group Group @relation(fields: [groupId], references: [id])
groupId String
time DateTime @default(now())
activityType ActivityType
participantId String?
expenseId String?
data String?
}
enum ActivityType {
UPDATE_GROUP
CREATE_EXPENSE
UPDATE_EXPENSE
DELETE_EXPENSE
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -1 +0,0 @@
<?xml version="1.0" ?><svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:#ccc;}.cls-2{fill:#ed5167;}.cls-3{fill:#72d3b8;}.cls-4{fill:#55bc9c;}.cls-5{fill:#e8e8e8;}.cls-6{fill:#333538;}</style></defs><title/><g data-name="1 funnel" id="_1_funnel"><path class="cls-1" d="M261.25,134.9h0a76.62,76.62,0,0,1,76.62,76.62V423.22a0,0,0,0,1,0,0H261.25a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(285.06 -130.08) rotate(45)"/><path class="cls-2" d="M161.87,470.93l75.07-75.08a17.06,17.06,0,0,0,0-24.14l-40.19-40.19a17.06,17.06,0,0,0-24.14,0l-10.8,10.8a91,91,0,0,0-129.58.92c-34.17,35.14-34.17,91.69,0,126.83a91,91,0,0,0,129.58.92ZM57,447.11a57.21,57.21,0,1,1,80.9,0A57.22,57.22,0,0,1,57,447.11Z"/><rect class="cls-3" height="253.49" rx="47.61" transform="translate(511.66 282.22) rotate(180)" width="344.78" x="83.44" y="14.36"/><path class="cls-4" d="M126,184.86V97.36a40.46,40.46,0,0,0,40.46-40.45H345.23a40.44,40.44,0,0,0,40.44,40.45v87.5a40.44,40.44,0,0,0-40.44,40.44H166.44A40.46,40.46,0,0,0,126,184.86Z"/><circle class="cls-3" cx="255.83" cy="141.11" r="60.11"/><circle class="cls-3" cx="349.72" cy="141.11" r="21.88"/><circle class="cls-3" cx="161.94" cy="141.11" r="21.88"/><path class="cls-5" d="M174.13,134.9h76.62a0,0,0,0,1,0,0V346.61a76.62,76.62,0,0,1-76.62,76.62h0a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(559.99 326.17) rotate(135)"/><path class="cls-2" d="M350.19,471a91,91,0,0,0,129.58-.92c34.17-35.14,34.17-91.69,0-126.83a91,91,0,0,0-129.58-.92l-10.8-10.8a17.06,17.06,0,0,0-24.14,0l-40.19,40.19a17.06,17.06,0,0,0,0,24.14l75.07,75.08Zm23.89-23.88a57.21,57.21,0,1,1,80.9,0A57.2,57.2,0,0,1,374.08,447.11Z"/><circle class="cls-6" cx="256" cy="322.62" r="20.01"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

12
scripts/build-image.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
SPLIIT_APP_NAME=$(node -p -e "require('./package.json').name")
SPLIIT_VERSION=$(node -p -e "require('./package.json').version")
# we need to set dummy data for POSTGRES env vars in order for build not to fail
docker buildx build \
-t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} \
-t ${SPLIIT_APP_NAME}:latest \
.
docker image prune -f

22
scripts/build.env Normal file
View File

@@ -0,0 +1,22 @@
# build file that contains all possible env vars with mocked values
# as most of them are used at build time in order to have the production build to work properly
# db
POSTGRES_PASSWORD=1234
# app
POSTGRES_PRISMA_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db
POSTGRES_URL_NON_POOLING=postgresql://postgres:${POSTGRES_PASSWORD}@db
# app-minio
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=false
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
S3_UPLOAD_BUCKET=spliit
S3_UPLOAD_REGION=eu-north-1
S3_UPLOAD_ENDPOINT=s3://minio.example.com
# app-openai
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=false
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT=false

View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -euxo pipefail
npx prisma migrate deploy
npm run start

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 ./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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,67 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 163 94% 24%;
--primary-foreground: 0 100% 100%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 142.1 76.2% 36.3%;
--radius: 0.5rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 0 0% 95%;
--card: 24 9.8% 10%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 95%;
--primary: 161 90% 45%;
--primary-foreground: 144.9 80.4% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 15%;
--muted-foreground: 240 5% 64.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 142.4 71.8% 29.2%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
.landing-header {
@apply bg-gradient-to-br from-emerald-800 to-emerald-600 dark:from-emerald-300 dark:to-emerald-600 bg-clip-text;
}
.landing-header strong {
@apply font-bold text-transparent;
}

View File

@@ -1,52 +0,0 @@
import { Balances } from '@/lib/balances'
import { cn } from '@/lib/utils'
import { Participant } from '@prisma/client'
type Props = {
balances: Balances
participants: Participant[]
currency: string
}
export function BalancesList({ balances, participants, currency }: Props) {
const maxBalance = Math.max(
...Object.values(balances).map((b) => Math.abs(b.total)),
)
return (
<div className="text-sm">
{participants.map((participant) => {
const balance = balances[participant.id]?.total ?? 0
const isLeft = balance >= 0
return (
<div
key={participant.id}
className={cn('flex', isLeft || 'flex-row-reverse')}
>
<div className={cn('w-1/2 p-2', isLeft && 'text-right')}>
{participant.name}
</div>
<div className={cn('w-1/2 relative', isLeft || 'text-right')}>
<div className="absolute inset-0 p-2 z-20">
{currency} {(balance / 100).toFixed(2)}
</div>
{balance !== 0 && (
<div
className={cn(
'absolute top-1 h-7 z-10',
isLeft
? 'bg-green-200 dark:bg-green-800 left-0 rounded-r-lg border border-green-300 dark:border-green-700'
: 'bg-red-200 dark:bg-red-800 right-0 rounded-l-lg border border-red-300 dark:border-red-700',
)}
style={{
width: (Math.abs(balance) / maxBalance) * 100 + '%',
}}
></div>
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,67 +0,0 @@
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 { getGroup, getGroupExpenses } from '@/lib/api'
import { getBalances, getSuggestedReimbursements } from '@/lib/balances'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Balances',
}
export default async function GroupPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
const group = await getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(groupId)
const balances = getBalances(expenses)
const reimbursements = getSuggestedReimbursements(balances)
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={balances}
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>
</>
)
}

View File

@@ -1,34 +0,0 @@
import { GroupForm } from '@/components/group-form'
import { getGroup, getGroupExpensesParticipants, updateGroup } from '@/lib/api'
import { groupFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
export const metadata: Metadata = {
title: 'Settings',
}
export default async function EditGroupPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
const group = await getGroup(groupId)
if (!group) notFound()
async function updateGroupAction(values: unknown) {
'use server'
const groupFormValues = groupFormSchema.parse(values)
const group = await updateGroup(groupId, groupFormValues)
redirect(`/groups/${group.id}`)
}
const protectedParticipantIds = await getGroupExpensesParticipants(groupId)
return (
<GroupForm
group={group}
onSubmit={updateGroupAction}
protectedParticipantIds={protectedParticipantIds}
/>
)
}

View File

@@ -1,42 +0,0 @@
import { ExpenseForm } from '@/components/expense-form'
import { deleteExpense, getExpense, getGroup, updateExpense } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
export const metadata: Metadata = {
title: 'Edit expense',
}
export default async function EditExpensePage({
params: { groupId, expenseId },
}: {
params: { groupId: string; expenseId: string }
}) {
const group = await getGroup(groupId)
if (!group) notFound()
const expense = await getExpense(groupId, expenseId)
if (!expense) notFound()
async function updateExpenseAction(values: unknown) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await updateExpense(groupId, expenseId, expenseFormValues)
redirect(`/groups/${groupId}`)
}
async function deleteExpenseAction() {
'use server'
await deleteExpense(expenseId)
redirect(`/groups/${groupId}`)
}
return (
<ExpenseForm
group={group}
expense={expense}
onSubmit={updateExpenseAction}
onDelete={deleteExpenseAction}
/>
)
}

View File

@@ -1,27 +0,0 @@
import { ExpenseForm } from '@/components/expense-form'
import { createExpense, getGroup } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
export const metadata: Metadata = {
title: 'Create expense',
}
export default async function ExpensePage({
params: { groupId },
}: {
params: { groupId: string }
}) {
const group = await getGroup(groupId)
if (!group) notFound()
async function createExpenseAction(values: unknown) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await createExpense(expenseFormValues, groupId)
redirect(`/groups/${groupId}`)
}
return <ExpenseForm group={group} onSubmit={createExpenseAction} />
}

View File

@@ -1,86 +0,0 @@
'use client'
import { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Participant } from '@prisma/client'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Fragment } from 'react'
type Props = {
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
participants: Participant[]
currency: string
groupId: string
}
export function ExpenseList({
expenses,
currency,
participants,
groupId,
}: Props) {
const getParticipant = (id: string) => participants.find((p) => p.id === id)
const router = useRouter()
return expenses.length > 0 ? (
expenses.map((expense) => (
<div
key={expense.id}
className={cn(
'border-t flex justify-between pl-6 pr-2 py-4 text-sm cursor-pointer hover:bg-accent',
expense.isReimbursement && 'italic',
)}
onClick={() => {
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
}}
>
<div>
<div className={cn('mb-1', expense.isReimbursement && 'italic')}>
{expense.title}
</div>
<div className="text-xs text-muted-foreground">
Paid by <strong>{getParticipant(expense.paidById)?.name}</strong>{' '}
for{' '}
{expense.paidFor.map((paidFor, index) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>
{
participants.find((p) => p.id === paidFor.participantId)
?.name
}
</strong>
</Fragment>
))}
</div>
</div>
<div className="flex items-center">
<div
className={cn(
'tabular-nums whitespace-nowrap',
expense.isReimbursement ? 'italic' : 'font-bold',
)}
>
{currency} {(expense.amount / 100).toFixed(2)}
</div>
<Button size="icon" variant="link" className="-my-2" asChild>
<Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
<ChevronRight className="w-4 h-4" />
</Link>
</Button>
</div>
</div>
))
) : (
<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>
)
}

View File

@@ -1,82 +0,0 @@
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 { getGroup, getGroupExpenses } from '@/lib/api'
import { Plus } from 'lucide-react'
import { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
export const metadata: Metadata = {
title: 'Expenses',
}
export default async function GroupExpensesPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
return (
<Card className="mb-4">
<div className="flex flex-1">
<CardHeader className="flex-1">
<CardTitle>Expenses</CardTitle>
<CardDescription>
Here are the expenses that you created for your group.
</CardDescription>
</CardHeader>
<CardHeader>
<Button asChild size="icon">
<Link href={`/groups/${groupId}/expenses/create`}>
<Plus />
</Link>
</Button>
</CardHeader>
</div>
<CardContent className="p-0">
<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 groupId={groupId} />
</Suspense>
</CardContent>
</Card>
)
}
async function Expenses({ groupId }: { groupId: string }) {
const group = await getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(group.id)
return (
<ExpenseList
expenses={expenses}
groupId={group.id}
currency={group.currency}
participants={group.participants}
/>
)
}

View File

@@ -1,30 +0,0 @@
'use client'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { usePathname, useRouter } from 'next/navigation'
type Props = {
groupId: string
}
export function GroupTabs({ groupId }: Props) {
const pathname = usePathname()
const value =
pathname.replace(/\/groups\/[^\/]+\/([^/]+).*/, '$1') || 'expenses'
const router = useRouter()
return (
<Tabs
value={value}
className="[&>*]:border"
onValueChange={(value) => {
router.push(`/groups/${groupId}/${value}`)
}}
>
<TabsList>
<TabsTrigger value="expenses">Expenses</TabsTrigger>
<TabsTrigger value="balances">Balances</TabsTrigger>
<TabsTrigger value="edit">Settings</TabsTrigger>
</TabsList>
</Tabs>
)
}

View File

@@ -1,54 +0,0 @@
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
import { ShareButton } from '@/app/groups/[groupId]/share-button'
import { getGroup } from '@/lib/api'
import { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { PropsWithChildren } from 'react'
type Props = {
params: {
groupId: string
}
}
export async function generateMetadata({
params: { groupId },
}: Props): Promise<Metadata> {
const group = await getGroup(groupId)
return {
title: {
default: group?.name ?? '',
template: `%s · ${group?.name} · Spliit`,
},
}
}
export default async function GroupLayout({
children,
params: { groupId },
}: PropsWithChildren<Props>) {
const group = await getGroup(groupId)
if (!group) notFound()
return (
<>
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-3">
<h1 className="font-bold text-2xl">
<Link href={`/groups/${groupId}`}>{group.name}</Link>
</h1>
<div className="flex gap-2 justify-between">
<GroupTabs groupId={groupId} />
<ShareButton group={group} />
</div>
</div>
{children}
<SaveGroupLocally group={{ id: group.id, name: group.name }} />
</>
)
}

View File

@@ -1,9 +0,0 @@
import { redirect } from 'next/navigation'
export default async function GroupPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
redirect(`/groups/${groupId}/expenses`)
}

View File

@@ -1,52 +0,0 @@
import { Button } from '@/components/ui/button'
import { Reimbursement } from '@/lib/balances'
import { Participant } from '@prisma/client'
import Link from 'next/link'
type Props = {
reimbursements: Reimbursement[]
participants: Participant[]
currency: string
groupId: string
}
export function ReimbursementList({
reimbursements,
participants,
currency,
groupId,
}: Props) {
if (reimbursements.length === 0) {
return (
<p className="px-6 text-sm pb-6">
It looks like your group doesnt need any reimbursement 😁
</p>
)
}
const getParticipant = (id: string) => participants.find((p) => p.id === id)
return (
<div className="text-sm">
{reimbursements.map((reimbursement, index) => (
<div className="border-t px-6 py-4 flex justify-between" key={index}>
<div className="flex flex-col gap-1 items-start sm:flex-row sm:items-baseline sm:gap-4">
<div>
<strong>{getParticipant(reimbursement.from)?.name}</strong> owes{' '}
<strong>{getParticipant(reimbursement.to)?.name}</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
</Link>
</Button>
</div>
<div>
{currency} {(reimbursement.amount / 100).toFixed(2)}
</div>
</div>
))}
</div>
)
}

View File

@@ -1,18 +0,0 @@
'use client'
import {
RecentGroup,
saveRecentGroup,
} from '@/app/groups/recent-groups-helpers'
import { useEffect } from 'react'
type Props = {
group: RecentGroup
}
export function SaveGroupLocally({ group }: Props) {
useEffect(() => {
saveRecentGroup(group)
}, [group])
return null
}

View File

@@ -1,48 +0,0 @@
import { CopyButton } from '@/components/copy-button'
import { ShareUrlButton } from '@/components/share-url-button'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { env } from '@/lib/env'
import { Group } from '@prisma/client'
import { Share } from 'lucide-react'
type Props = {
group: Group
}
export function ShareButton({ group }: Props) {
const url = `${env.NEXT_PUBLIC_BASE_URL}/groups/${group.id}/expenses?ref=share`
return (
<Popover>
<PopoverTrigger asChild>
<Button size="icon">
<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>
<div className="flex gap-2">
<Input className="flex-1" defaultValue={url} readOnly />
<CopyButton text={url} />
<ShareUrlButton
text={`Join my group ${group.name} on Spliit`}
url={url}
/>
</div>
<p>
<strong>Warning!</strong> Every person with the group URL will be able
to see and edit expenses. Share with caution!
</p>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,7 +0,0 @@
'use server'
import { getGroups } from "@/lib/api"
export async function getGroupsAction(groupIds: string[]) {
'use server'
return getGroups(groupIds)
}

View File

@@ -1,15 +0,0 @@
import { GroupForm } from '@/components/group-form'
import { createGroup } from '@/lib/api'
import { groupFormSchema } from '@/lib/schemas'
import { redirect } from 'next/navigation'
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} />
}

View File

@@ -1,9 +0,0 @@
import { PropsWithChildren } from 'react'
export default function GroupsLayout({ children }: PropsWithChildren<{}>) {
return (
<main className="flex-1 max-w-screen-md w-full mx-auto px-4 py-6 flex flex-col gap-6">
{children}
</main>
)
}

View File

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

View File

@@ -1,28 +0,0 @@
import { RecentGroupList } from '@/app/groups/recent-group-list'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import { Metadata } from 'next'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'Recently visited groups',
}
export default async function GroupsPage() {
return (
<>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 items-start">
<h1 className="font-bold text-2xl">
<Link href="/groups">Recently visited groups</Link>
</h1>
<Button asChild>
<Link href="/groups/create">
<Plus className="w-4 h-4 mr-2" />
Create group
</Link>
</Button>
</div>
<RecentGroupList />
</>
)
}

View File

@@ -1,114 +0,0 @@
'use client'
import { getGroupsAction } from '@/app/groups/actions'
import { getRecentGroups } from '@/app/groups/recent-groups-helpers'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { getGroups } from '@/lib/api'
import { Calendar, Loader2, Users } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { z } from 'zod'
const recentGroupsSchema = z.array(
z.object({
id: z.string().min(1),
name: z.string(),
}),
)
type RecentGroups = z.infer<typeof recentGroupsSchema>
type State =
| { status: 'pending' }
| { status: 'partial'; groups: RecentGroups }
| {
status: 'complete'
groups: RecentGroups
groupsDetails: Awaited<ReturnType<typeof getGroups>>
}
type Props = {
getGroupsAction: (groupIds: string[]) => ReturnType<typeof getGroups>
}
export function RecentGroupList() {
const [state, setState] = useState<State>({ status: 'pending' })
useEffect(() => {
const groupsInStorage = getRecentGroups()
setState({ status: 'partial', groups: groupsInStorage })
getGroupsAction(groupsInStorage.map((g) => g.id)).then((groupsDetails) => {
setState({ status: 'complete', groups: groupsInStorage, groupsDetails })
})
}, [])
if (state.status === 'pending') {
return (
<p>
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" /> Loading recent
groups
</p>
)
}
if (state.groups.length === 0) {
return (
<div className="text-sm space-y-2">
<p>You have not visited any group recently.</p>
<p>
<Button variant="link" asChild className="-m-4">
<Link href={`/groups/create`}>Create one</Link>
</Button>{' '}
or ask a friend to send you the link to an existing one.
</p>
</div>
)
}
return (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{state.groups.map((group) => {
const details =
state.status === 'complete'
? state.groupsDetails.find((d) => d.id === group.id)
: null
return (
<li key={group.id}>
<Button variant="outline" className="h-fit w-full py-3" asChild>
<Link href={`/groups/${group.id}`} className="text-base">
<div className="w-full flex flex-col gap-1">
<div className="text-base">{group.name}</div>
<div className="text-muted-foreground font-normal text-xs">
{details ? (
<div className="w-full flex items-center justify-between">
<div className="flex items-center">
<Users className="w-3 h-3 inline mr-1" />
<span>{details._count.participants}</span>
</div>
<div className="flex items-center">
<Calendar className="w-3 h-3 inline mx-1" />
<span>
{new Date(details.createdAt).toLocaleDateString(
'en-US',
{
dateStyle: 'medium',
},
)}
</span>
</div>
</div>
) : (
<div className="flex justify-between">
<Skeleton className="h-4 w-6 rounded-full" />
<Skeleton className="h-4 w-24 rounded-full" />
</div>
)}
</div>
</div>
</Link>
</Button>
</li>
)
})}
</ul>
)
}

View File

@@ -1,30 +0,0 @@
import { z } from 'zod'
export const recentGroupsSchema = z.array(
z.object({
id: z.string().min(1),
name: z.string(),
}),
)
export type RecentGroups = z.infer<typeof recentGroupsSchema>
export type RecentGroup = RecentGroups[number]
const STORAGE_KEY = 'recentGroups'
export function getRecentGroups() {
const groupsInStorageJson = localStorage.getItem(STORAGE_KEY)
const groupsInStorageRaw = groupsInStorageJson
? JSON.parse(groupsInStorageJson)
: []
const parseResult = recentGroupsSchema.safeParse(groupsInStorageRaw)
return parseResult.success ? parseResult.data : []
}
export function saveRecentGroup(group: RecentGroup) {
const recentGroups = getRecentGroups()
localStorage.setItem(
STORAGE_KEY,
JSON.stringify([group, ...recentGroups.filter((rg) => rg.id !== group.id)]),
)
}

View File

@@ -1 +0,0 @@
<?xml version="1.0" ?><svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:#ccc;}.cls-2{fill:#ed5167;}.cls-3{fill:#72d3b8;}.cls-4{fill:#55bc9c;}.cls-5{fill:#e8e8e8;}.cls-6{fill:#333538;}</style></defs><title/><g data-name="1 funnel" id="_1_funnel"><path class="cls-1" d="M261.25,134.9h0a76.62,76.62,0,0,1,76.62,76.62V423.22a0,0,0,0,1,0,0H261.25a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(285.06 -130.08) rotate(45)"/><path class="cls-2" d="M161.87,470.93l75.07-75.08a17.06,17.06,0,0,0,0-24.14l-40.19-40.19a17.06,17.06,0,0,0-24.14,0l-10.8,10.8a91,91,0,0,0-129.58.92c-34.17,35.14-34.17,91.69,0,126.83a91,91,0,0,0,129.58.92ZM57,447.11a57.21,57.21,0,1,1,80.9,0A57.22,57.22,0,0,1,57,447.11Z"/><rect class="cls-3" height="253.49" rx="47.61" transform="translate(511.66 282.22) rotate(180)" width="344.78" x="83.44" y="14.36"/><path class="cls-4" d="M126,184.86V97.36a40.46,40.46,0,0,0,40.46-40.45H345.23a40.44,40.44,0,0,0,40.44,40.45v87.5a40.44,40.44,0,0,0-40.44,40.44H166.44A40.46,40.46,0,0,0,126,184.86Z"/><circle class="cls-3" cx="255.83" cy="141.11" r="60.11"/><circle class="cls-3" cx="349.72" cy="141.11" r="21.88"/><circle class="cls-3" cx="161.94" cy="141.11" r="21.88"/><path class="cls-5" d="M174.13,134.9h76.62a0,0,0,0,1,0,0V346.61a76.62,76.62,0,0,1-76.62,76.62h0a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(559.99 326.17) rotate(135)"/><path class="cls-2" d="M350.19,471a91,91,0,0,0,129.58-.92c34.17-35.14,34.17-91.69,0-126.83a91,91,0,0,0-129.58-.92l-10.8-10.8a17.06,17.06,0,0,0-24.14,0l-40.19,40.19a17.06,17.06,0,0,0,0,24.14l75.07,75.08Zm23.89-23.88a57.21,57.21,0,1,1,80.9,0A57.2,57.2,0,0,1,374.08,447.11Z"/><circle class="cls-6" cx="256" cy="322.62" r="20.01"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,139 +0,0 @@
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 { env } from '@/lib/env'
import type { Metadata, Viewport } from 'next'
import PlausibleProvider from 'next-plausible'
import Image from 'next/image'
import Link from 'next/link'
import './globals.css'
export const metadata: Metadata = {
metadataBase: new URL(env.NEXT_PUBLIC_BASE_URL),
title: {
default: 'Spliit · Share Expenses with Friends & Family',
template: '%s · Spliit',
},
description:
'Spliit is a minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
openGraph: {
title: 'Spliit · Share Expenses with Friends & Family',
description:
'Spliit is a minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
images: `/banner.png`,
type: 'website',
url: '/',
},
twitter: {
card: 'summary_large_image',
creator: '@scastiel',
site: '@scastiel',
images: `/banner.png`,
title: 'Spliit · Share Expenses with Friends & Family',
description:
'Spliit is a minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
},
appleWebApp: {
capable: true,
title: 'Spliit',
},
applicationName: 'Spliit',
icons: [
{
url: '/android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
url: '/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
}
export const viewport: Viewport = {
themeColor: '#047857',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
{env.PLAUSIBLE_DOMAIN && (
<PlausibleProvider domain={env.PLAUSIBLE_DOMAIN} trackOutboundLinks />
)}
<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
>
<ProgressBar />
<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">
<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"
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"
asChild
className="-my-3 text-primary"
>
<Link href="/groups">Groups</Link>
</Button>
</li>
<li>
<ThemeToggle />
</li>
</ul>
</div>
</header>
{children}
<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 space-y-2 text-xs [&_a]:underline">
<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"
width={(35 * 522) / 180}
height={35}
alt="Spliit"
/>
</Link>
</div>
<div className="flex flex-col space-y a--no-underline-text-white">
<span>Made in Montréal, Québec 🇨🇦</span>
<span>
Built by{' '}
<a href="https://scastiel.dev" target="_blank" rel="noopener">
Sebastien Castiel
</a>
</span>
</div>
</footer>
</ThemeProvider>
</body>
</html>
)
}

View File

@@ -1,32 +0,0 @@
import { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Spliit',
short_name: 'Spliit',
description:
'A minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
start_url: '/',
display: 'standalone',
background_color: '#fff',
theme_color: '#047857',
icons: [
{
src: '/android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: '/logo-512x512-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
}
}

View File

@@ -1,144 +0,0 @@
import { Button } from '@/components/ui/button'
import {
BarChartHorizontalBig,
CircleDollarSign,
Github,
List,
LucideIcon,
Share,
ShieldX,
Users,
} from 'lucide-react'
import Link from 'next/link'
import { ReactNode } from 'react'
// FIX for https://github.com/vercel/next.js/issues/58615
export const dynamic = 'force-dynamic'
export default function HomePage() {
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>
<p className="max-w-[42rem] leading-normal text-muted-foreground sm:text-xl sm:leading-8">
No ads. No account. <br className="sm:hidden" /> Open Source.
Forever Free.
</p>
<div className="flex gap-2">
<Button asChild size="lg">
<Link
className="inline-flex items-center justify-center text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 rounded-md"
href="/groups/create"
>
Create a group
</Link>
</Button>
</div>
</div>
</section>
<section className="bg-slate-50 dark:bg-card py-16 md:py-24 lg:py-32">
<div className="p-4 flex mx-auto max-w-screen-md flex-col items-center text-center">
<h2 className="font-bold text-3xl leading-[1.1] sm:text-3xl md:text-6xl">
Features
</h2>
<p
className="mt-2 md:mt-3 leading-normal text-muted-foreground sm:text-lg sm:leading-7"
style={{ textWrap: 'balance' } as any}
>
Spliit is a minimalist application to track and share expenses with
your friends and family.
</p>
<div className="mt-8 md:mt-6 w-full grid grid-cols-2 sm:grid-cols-3 gap-2 sm:gap-4 text-left">
<Feature
Icon={Users}
name="Groups"
description="Create a group for a travel, an event, a gift…"
/>
<Feature
Icon={List}
name="Expenses"
description="Create and list expenses in your group."
/>
<Feature
Icon={Share}
name="Share"
description="Send the group link to participants."
/>
<Feature
Icon={BarChartHorizontalBig}
name="Balances"
description="Visualize how much each participant spent."
/>
<Feature
Icon={CircleDollarSign}
name="Reimbursements"
description="Optimize money transfers between participants."
/>
<Feature
Icon={ShieldX}
name="No ads"
description="No account. No limitation. No problem."
/>
</div>
</div>
</section>
<section className="py-16 md:py-24 lg:py-32">
<div className="container flex max-w-screen-md flex-col items-center text-center">
<h2 className="font-bold text-3xl leading-[1.1] sm:text-3xl md:text-6xl">
Proudly Open Source
</h2>
<p
className="mt-2 leading-normal text-muted-foreground sm:text-lg sm:leading-7"
style={{ textWrap: 'balance' } as any}
>
Spliit is open source and powered by open source software. Feel free
to contribute!
</p>
<div className="mt-4 md:mt-6">
<Button asChild variant="secondary" size="lg">
<a
target="_blank"
rel="noreferrer"
href="https://github.com/scastiel/spliit2"
>
<Github className="w-4 h-4 mr-2" />
GitHub
</a>
</Button>
</div>
</div>
</section>
</main>
)
}
function Feature({
name,
Icon,
description,
}: {
name: ReactNode
Icon: LucideIcon
description: ReactNode
}) {
return (
<div className="bg-card border rounded-md p-4 flex flex-col gap-2">
<Icon className="w-8 h-8" />
<div>
<strong>{name}</strong>
</div>
<div
className="text-sm text-muted-foreground"
style={{ textWrap: 'balance' } as any}
>
{description}
</div>
</div>
)
}

View File

@@ -1,13 +0,0 @@
import { env } from '@/lib/env'
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: '/groups/',
},
sitemap: `${env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
}
}

View File

@@ -1,13 +0,0 @@
import { env } from '@/lib/env'
import { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: env.NEXT_PUBLIC_BASE_URL,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1,
},
]
}

View File

@@ -1,42 +0,0 @@
'use client'
import { Button, ButtonProps } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { ReactNode, useState } from 'react'
type Props = ButtonProps & {
action?: () => Promise<void>
loadingContent?: ReactNode
}
export function AsyncButton({
action,
children,
loadingContent,
...props
}: Props) {
const [loading, setLoading] = useState(false)
return (
<Button
onClick={async () => {
try {
setLoading(true)
await action?.()
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}}
{...props}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />{' '}
{loadingContent ?? children}
</>
) : (
children
)}
</Button>
)
}

View File

@@ -1,34 +0,0 @@
'use client'
import { Button } from '@/components/ui/button'
import { Check, Copy } from 'lucide-react'
import { useEffect, useState } from 'react'
type Props = { text: string }
export function CopyButton({ text }: Props) {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (copied) {
let timeout = setTimeout(() => setCopied(false), 1000)
return () => {
setCopied(false)
clearTimeout(timeout)
}
}
}, [copied])
return (
<Button
size="icon"
variant="secondary"
type="button"
onClick={() => {
navigator.clipboard.writeText(text)
setCopied(true)
}}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
)
}

View File

@@ -1,269 +0,0 @@
'use client'
import { AsyncButton } from '@/components/async-button'
import { SubmitButton } from '@/components/submit-button'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { getExpense, getGroup } from '@/lib/api'
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
import { zodResolver } from '@hookform/resolvers/zod'
import { useSearchParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
onSubmit: (values: ExpenseFormValues) => Promise<void>
onDelete?: () => Promise<void>
}
export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
const isCreate = expense === undefined
const searchParams = useSearchParams()
const form = useForm<ExpenseFormValues>({
resolver: zodResolver(expenseFormSchema),
defaultValues: expense
? {
title: expense.title,
amount: String(expense.amount / 100) as unknown as number, // hack
paidBy: expense.paidById,
paidFor: expense.paidFor.map(({ participantId }) => participantId),
isReimbursement: expense.isReimbursement,
}
: searchParams.get('reimbursement')
? {
title: 'Reimbursement',
amount: String(
(Number(searchParams.get('amount')) || 0) / 100,
) as unknown as number, // hack
paidBy: searchParams.get('from') ?? undefined,
paidFor: [searchParams.get('to') ?? undefined],
isReimbursement: true,
}
: { title: '', amount: 0, paidFor: [], isReimbursement: false },
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
<Card>
<CardHeader>
<CardTitle>
{isCreate ? <>Create expense</> : <>Edit expense</>}
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="order-1">
<FormLabel>Expense title</FormLabel>
<FormControl>
<Input
placeholder="Monday evening restaurant"
className="text-base"
{...field}
/>
</FormControl>
<FormDescription>
Enter a description for the expense.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="paidBy"
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>Paid by</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a participant" />
</SelectTrigger>
<SelectContent>
{group.participants.map(({ id, name }) => (
<SelectItem key={id} value={id}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select the participant who paid the expense.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="order-2 sm:order-3">
<FormLabel>Amount</FormLabel>
<div className="flex items-baseline gap-2">
<span>{group.currency}</span>
<FormControl>
<Input
className="text-base max-w-[120px]"
type="number"
inputMode="decimal"
step={0.01}
placeholder="0.00"
{...field}
/>
</FormControl>
</div>
<FormMessage />
<FormField
control={form.control}
name="isReimbursement"
render={({ field }) => (
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div>
<FormLabel>This is a reimbursement</FormLabel>
</div>
</FormItem>
)}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="paidFor"
render={() => (
<FormItem className="order-5">
<div className="mb-4">
<FormLabel>
Paid for
<Button
variant="link"
type="button"
className="-m-2"
onClick={() => {
const paidFor = form.getValues().paidFor
const allSelected =
paidFor.length === group.participants.length
const newPairFor = allSelected
? []
: group.participants.map((p) => p.id)
form.setValue('paidFor', newPairFor, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}}
>
{form.getValues().paidFor.length ===
group.participants.length ? (
<>Select none</>
) : (
<>Select all</>
)}
</Button>
</FormLabel>
<FormDescription>
Select who the expense was paid for.
</FormDescription>
</div>
{group.participants.map(({ id, name }) => (
<FormField
key={id}
control={form.control}
name="paidFor"
render={({ field }) => {
return (
<FormItem
key={id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, id])
: field.onChange(
field.value?.filter(
(value) => value !== id,
),
)
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
{name}
</FormLabel>
</FormItem>
)
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter className="gap-2">
<SubmitButton
loadingContent={isCreate ? <>Creating</> : <>Saving</>}
>
{isCreate ? <>Create</> : <>Save</>}
</SubmitButton>
{!isCreate && onDelete && (
<AsyncButton
type="button"
variant="destructive"
loadingContent="Deleting…"
action={onDelete}
>
Delete
</AsyncButton>
)}
</CardFooter>
</Card>
</form>
</Form>
)
}

View File

@@ -1,208 +0,0 @@
'use client'
import { SubmitButton } from '@/components/submit-button'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { Input } from '@/components/ui/input'
import { getGroup } from '@/lib/api'
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
import { zodResolver } from '@hookform/resolvers/zod'
import { Save, Trash2 } from 'lucide-react'
import { useFieldArray, useForm } from 'react-hook-form'
export type Props = {
group?: NonNullable<Awaited<ReturnType<typeof getGroup>>>
onSubmit: (groupFormValues: GroupFormValues) => Promise<void>
protectedParticipantIds?: string[]
}
export function GroupForm({
group,
onSubmit,
protectedParticipantIds = [],
}: Props) {
const form = useForm<GroupFormValues>({
resolver: zodResolver(groupFormSchema),
defaultValues: group
? {
name: group.name,
currency: group.currency,
participants: group.participants,
}
: {
name: '',
currency: '',
participants: [{ name: 'John' }, { name: 'Jane' }, { name: 'Jack' }],
},
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'participants',
keyName: 'key',
})
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (values) => {
await onSubmit(values)
})}
>
<Card className="mb-4">
<CardHeader>
<CardTitle>Group information</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Group name</FormLabel>
<FormControl>
<Input
className="text-base"
placeholder="Summer vacations"
{...field}
/>
</FormControl>
<FormDescription>
Enter a name for your group.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Currency symbol</FormLabel>
<FormControl>
<Input
className="text-base"
placeholder="$, €, £…"
max={5}
{...field}
/>
</FormControl>
<FormDescription>
Well use it to display amounts.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card className="mb-4">
<CardHeader>
<CardTitle>Participants</CardTitle>
<CardDescription>
Enter the name for each participant
</CardDescription>
</CardHeader>
<CardContent>
<ul className="flex flex-col gap-2">
{fields.map((item, index) => (
<li key={item.key}>
<FormField
control={form.control}
name={`participants.${index}.name`}
render={({ field }) => (
<FormItem>
<FormLabel className="sr-only">
Participant #{index + 1}
</FormLabel>
<FormControl>
<div className="flex gap-2">
<Input className="text-base" {...field} />
{item.id &&
protectedParticipantIds.includes(item.id) ? (
<HoverCard>
<HoverCardTrigger>
<Button
variant="ghost"
className="text-destructive-"
type="button"
size="icon"
disabled
>
<Trash2 className="w-4 h-4 text-destructive opacity-50" />
</Button>
</HoverCardTrigger>
<HoverCardContent
align="end"
className="text-sm"
>
This participant is part of expenses, and can
not be removed.
</HoverCardContent>
</HoverCard>
) : (
<Button
variant="ghost"
className="text-destructive"
onClick={() => remove(index)}
type="button"
size="icon"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
variant="secondary"
onClick={() => {
append({ name: 'New' })
}}
type="button"
>
Add participant
</Button>
</CardFooter>
</Card>
<SubmitButton
size="lg"
loadingContent={group ? 'Saving…' : 'Creating…'}
>
<Save className="w-4 h-4 mr-2" /> {group ? <>Save</> : <> Create</>}
</SubmitButton>
</form>
</Form>
)
}

View File

@@ -1,13 +0,0 @@
'use client'
import { Next13ProgressBar } from 'next13-progressbar'
export function ProgressBar() {
return (
<Next13ProgressBar
height="2px"
color="#64748b"
options={{ showSpinner: false }}
showOnShallow
/>
)
}

View File

@@ -1,44 +0,0 @@
'use client'
import { Button } from '@/components/ui/button'
import { Share } from 'lucide-react'
import { useEffect, useState } from 'react'
interface Props {
text: string
url: string
}
export function ShareUrlButton({ url, text }: Props) {
const canShare = useCanShare(url, text)
if (!canShare) return null
return (
<Button
size="icon"
variant="secondary"
type="button"
onClick={() => {
if (navigator.share) {
navigator.share({ text, url })
} else {
console.log('Sharing is not available', { text, url })
}
}}
>
<Share className="w-4 h-4" />
</Button>
)
}
function useCanShare(url: string, text: string) {
const [canShare, setCanShare] = useState<boolean | null>(null)
useEffect(() => {
setCanShare(
navigator.share !== undefined && navigator.canShare({ url, text }),
)
}, [text, url])
return canShare
}

View File

@@ -1,23 +0,0 @@
import { Button, ButtonProps } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { ReactNode } from 'react'
import { useFormState } from 'react-hook-form'
type Props = {
loadingContent: ReactNode
} & ButtonProps
export function SubmitButton({ children, loadingContent, ...props }: Props) {
const { isSubmitting } = useFormState()
return (
<Button type="submit" disabled={isSubmitting} {...props}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> {loadingContent}
</>
) : (
children
)}
</Button>
)
}

View File

@@ -1,8 +0,0 @@
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -1,39 +0,0 @@
'use client'
import { MoonIcon, SunIcon } from '@radix-ui/react-icons'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="text-primary">
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,59 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,36 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -1,56 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -1,79 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -1,30 +0,0 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -1,200 +0,0 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -1,176 +0,0 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -1,29 +0,0 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -1,25 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -1,26 +0,0 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -1,31 +0,0 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -1,160 +0,0 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -1,15 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -1,117 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -1,55 +0,0 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

5
src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export {
appRouter,
type AppRouter,
type AppRouterOutput,
} from './trpc/routers/_app'

View File

@@ -1,18 +1,18 @@
import { getPrisma } from '@/lib/prisma'
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
import { Expense } from '@prisma/client'
import { ActivityType, Expense } from '@prisma/client'
import { nanoid } from 'nanoid'
import { prisma } from './prisma'
import { ExpenseFormValues, GroupFormValues } from './schemas'
export function randomId() {
return nanoid()
}
export async function createGroup(groupFormValues: GroupFormValues) {
const prisma = await getPrisma()
return prisma.group.create({
data: {
id: randomId(),
name: groupFormValues.name,
information: groupFormValues.information,
currency: groupFormValues.currency,
participants: {
createMany: {
@@ -30,40 +30,72 @@ export async function createGroup(groupFormValues: GroupFormValues) {
export async function createExpense(
expenseFormValues: ExpenseFormValues,
groupId: string,
participantId?: string,
): Promise<Expense> {
const group = await getGroup(groupId)
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
for (const participant of [
expenseFormValues.paidBy,
...expenseFormValues.paidFor,
...expenseFormValues.paidFor.map((p) => p.participant),
]) {
if (!group.participants.some((p) => p.id === participant))
throw new Error(`Invalid participant ID: ${participant}`)
}
const prisma = await getPrisma()
const expenseId = randomId()
await logActivity(groupId, ActivityType.CREATE_EXPENSE, {
participantId,
expenseId,
data: expenseFormValues.title,
})
return prisma.expense.create({
data: {
id: randomId(),
id: expenseId,
groupId,
expenseDate: expenseFormValues.expenseDate,
categoryId: expenseFormValues.category,
amount: expenseFormValues.amount,
title: expenseFormValues.title,
paidById: expenseFormValues.paidBy,
splitMode: expenseFormValues.splitMode,
paidFor: {
createMany: {
data: expenseFormValues.paidFor.map((paidFor) => ({
participantId: paidFor,
participantId: paidFor.participant,
shares: paidFor.shares,
})),
},
},
isReimbursement: expenseFormValues.isReimbursement,
documents: {
createMany: {
data: expenseFormValues.documents.map((doc) => ({
id: randomId(),
url: doc.url,
width: doc.width,
height: doc.height,
})),
},
},
notes: expenseFormValues.notes,
},
})
}
export async function deleteExpense(expenseId: string) {
const prisma = await getPrisma()
export async function deleteExpense(
groupId: string,
expenseId: string,
participantId?: string,
) {
const existingExpense = await getExpense(groupId, expenseId)
await logActivity(groupId, ActivityType.DELETE_EXPENSE, {
participantId,
expenseId,
data: existingExpense?.title,
})
await prisma.expense.delete({
where: { id: expenseId },
include: { paidFor: true, paidBy: true },
@@ -75,21 +107,22 @@ export async function getGroupExpensesParticipants(groupId: string) {
return Array.from(
new Set(
expenses.flatMap((e) => [
e.paidById,
...e.paidFor.map((pf) => pf.participantId),
e.paidBy.id,
...e.paidFor.map((pf) => pf.participant.id),
]),
),
)
}
export async function getGroups(groupIds: string[]) {
const prisma = await getPrisma()
return (await prisma.group.findMany({
where: { id: { in: groupIds } },
include: { _count: { select: { participants: true } } },
})).map(group => ({
return (
await prisma.group.findMany({
where: { id: { in: groupIds } },
include: { _count: { select: { participants: true } } },
})
).map((group) => ({
...group,
createdAt: group.createdAt.toISOString()
createdAt: group.createdAt.toISOString(),
}))
}
@@ -97,6 +130,7 @@ export async function updateExpense(
groupId: string,
expenseId: string,
expenseFormValues: ExpenseFormValues,
participantId?: string,
) {
const group = await getGroup(groupId)
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
@@ -106,34 +140,75 @@ export async function updateExpense(
for (const participant of [
expenseFormValues.paidBy,
...expenseFormValues.paidFor,
...expenseFormValues.paidFor.map((p) => p.participant),
]) {
if (!group.participants.some((p) => p.id === participant))
throw new Error(`Invalid participant ID: ${participant}`)
}
const prisma = await getPrisma()
await logActivity(groupId, ActivityType.UPDATE_EXPENSE, {
participantId,
expenseId,
data: expenseFormValues.title,
})
return prisma.expense.update({
where: { id: expenseId },
data: {
expenseDate: expenseFormValues.expenseDate,
amount: expenseFormValues.amount,
title: expenseFormValues.title,
categoryId: expenseFormValues.category,
paidById: expenseFormValues.paidBy,
splitMode: expenseFormValues.splitMode,
paidFor: {
connectOrCreate: expenseFormValues.paidFor.map((paidFor) => ({
create: expenseFormValues.paidFor
.filter(
(p) =>
!existingExpense.paidFor.some(
(pp) => pp.participantId === p.participant,
),
)
.map((paidFor) => ({
participantId: paidFor.participant,
shares: paidFor.shares,
})),
update: expenseFormValues.paidFor.map((paidFor) => ({
where: {
expenseId_participantId: { expenseId, participantId: paidFor },
expenseId_participantId: {
expenseId,
participantId: paidFor.participant,
},
},
data: {
shares: paidFor.shares,
},
create: { participantId: paidFor },
})),
deleteMany: existingExpense.paidFor.filter(
(paidFor) =>
!expenseFormValues.paidFor.some(
(pf) => pf === paidFor.participantId,
(pf) => pf.participant === paidFor.participantId,
),
),
},
isReimbursement: expenseFormValues.isReimbursement,
documents: {
connectOrCreate: expenseFormValues.documents.map((doc) => ({
create: doc,
where: { id: doc.id },
})),
deleteMany: existingExpense.documents
.filter(
(existingDoc) =>
!expenseFormValues.documents.some(
(doc) => doc.id === existingDoc.id,
),
)
.map((doc) => ({
id: doc.id,
})),
},
notes: expenseFormValues.notes,
},
})
}
@@ -141,15 +216,18 @@ export async function updateExpense(
export async function updateGroup(
groupId: string,
groupFormValues: GroupFormValues,
participantId?: string,
) {
const existingGroup = await getGroup(groupId)
if (!existingGroup) throw new Error('Invalid group ID')
const prisma = await getPrisma()
await logActivity(groupId, ActivityType.UPDATE_GROUP, { participantId })
return prisma.group.update({
where: { id: groupId },
data: {
name: groupFormValues.name,
information: groupFormValues.information,
currency: groupFormValues.currency,
participants: {
deleteMany: existingGroup.participants.filter(
@@ -177,26 +255,102 @@ export async function updateGroup(
}
export async function getGroup(groupId: string) {
const prisma = await getPrisma()
return prisma.group.findUnique({
where: { id: groupId },
include: { participants: true },
})
}
export async function getGroupExpenses(groupId: string) {
const prisma = await getPrisma()
export async function getCategories() {
return prisma.category.findMany()
}
export async function getGroupExpenses(
groupId: string,
options?: { offset?: number; length?: number; filter?: string },
) {
return prisma.expense.findMany({
where: { groupId },
include: { paidFor: { include: { participant: true } }, paidBy: true },
orderBy: { createdAt: 'desc' },
select: {
amount: true,
category: true,
createdAt: true,
expenseDate: true,
id: true,
isReimbursement: true,
paidBy: { select: { id: true, name: true } },
paidFor: {
select: {
participant: { select: { id: true, name: true } },
shares: true,
},
},
splitMode: true,
title: true,
},
where: {
groupId,
title: options?.filter
? { contains: options.filter, mode: 'insensitive' }
: undefined,
},
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
skip: options && options.offset,
take: options && options.length,
})
}
export async function getGroupExpenseCount(groupId: string) {
return prisma.expense.count({ where: { groupId } })
}
export async function getExpense(groupId: string, expenseId: string) {
const prisma = await getPrisma()
return prisma.expense.findUnique({
where: { id: expenseId },
include: { paidBy: true, paidFor: true },
include: { paidBy: true, paidFor: true, category: true, documents: true },
})
}
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(
groupId: string,
activityType: ActivityType,
extra?: { participantId?: string; expenseId?: string; data?: string },
) {
return prisma.activity.create({
data: {
id: randomId(),
groupId,
activityType,
...extra,
},
})
}

View File

@@ -1,5 +1,6 @@
import { getGroupExpenses } from '@/lib/api'
import { Participant } from '@prisma/client'
import { match } from 'ts-pattern'
import { getGroupExpenses } from './api'
export type Balances = Record<
Participant['id'],
@@ -18,33 +19,86 @@ export function getBalances(
const balances: Balances = {}
for (const expense of expenses) {
const paidBy = expense.paidById
const paidFors = expense.paidFor.map((p) => p.participantId)
const paidBy = expense.paidBy.id
const paidFors = expense.paidFor
if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 }
balances[paidBy].paid += expense.amount
balances[paidBy].total += expense.amount
paidFors.forEach((paidFor, index) => {
if (!balances[paidFor])
balances[paidFor] = { paid: 0, paidFor: 0, total: 0 }
const dividedAmount = divide(
expense.amount,
paidFors.length,
index === paidFors.length - 1,
)
balances[paidFor].paidFor += dividedAmount
balances[paidFor].total -= dividedAmount
const totalPaidForShares = paidFors.reduce(
(sum, paidFor) => sum + paidFor.shares,
0,
)
let remaining = expense.amount
paidFors.forEach((paidFor, index) => {
if (!balances[paidFor.participant.id])
balances[paidFor.participant.id] = { paid: 0, paidFor: 0, total: 0 }
const isLast = index === paidFors.length - 1
const [shares, totalShares] = match(expense.splitMode)
.with('EVENLY', () => [1, paidFors.length])
.with('BY_SHARES', () => [paidFor.shares, totalPaidForShares])
.with('BY_PERCENTAGE', () => [paidFor.shares, totalPaidForShares])
.with('BY_AMOUNT', () => [paidFor.shares, totalPaidForShares])
.exhaustive()
const dividedAmount = isLast
? remaining
: (expense.amount * shares) / totalShares
remaining -= dividedAmount
balances[paidFor.participant.id].paidFor += dividedAmount
})
}
// rounding and add total
for (const participantId in balances) {
// add +0 to avoid negative zeros
balances[participantId].paidFor =
Math.round(balances[participantId].paidFor) + 0
balances[participantId].paid = Math.round(balances[participantId].paid) + 0
balances[participantId].total =
balances[participantId].paid - balances[participantId].paidFor
}
return balances
}
function divide(total: number, count: number, isLast: boolean): number {
if (!isLast) return Math.floor(total / count)
export function getPublicBalances(reimbursements: Reimbursement[]): Balances {
const balances: Balances = {}
reimbursements.forEach((reimbursement) => {
if (!balances[reimbursement.from])
balances[reimbursement.from] = { paid: 0, paidFor: 0, total: 0 }
return total - divide(total, count, false) * (count - 1)
if (!balances[reimbursement.to])
balances[reimbursement.to] = { paid: 0, paidFor: 0, total: 0 }
balances[reimbursement.from].paidFor += reimbursement.amount
balances[reimbursement.from].total -= reimbursement.amount
balances[reimbursement.to].paid += reimbursement.amount
balances[reimbursement.to].total += reimbursement.amount
})
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: { total: number; participantId: string },
b2: { total: number; participantId: string },
): 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.localeCompare(b2.participantId)
}
export function getSuggestedReimbursements(
@@ -53,7 +107,7 @@ export function getSuggestedReimbursements(
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]
@@ -77,5 +131,5 @@ export function getSuggestedReimbursements(
balancesArray.shift()
}
}
return reimbursements.filter(({ amount }) => amount !== 0)
return reimbursements.filter(({ amount }) => Math.round(amount) + 0 !== 0)
}

View File

@@ -1,10 +1,8 @@
import { z } from 'zod'
const envSchema = z.object({
NEXT_PUBLIC_BASE_URL: z.string().url(),
POSTGRES_URL_NON_POOLING: z.string().url(),
POSTGRES_PRISMA_URL: z.string().url(),
PLAUSIBLE_DOMAIN: z.string().optional(),
})
export const env = envSchema.parse(process.env)

View File

@@ -1,18 +1,21 @@
import { PrismaClient } from '@prisma/client'
let prisma: PrismaClient
declare const global: Global & { prisma?: PrismaClient }
export async function getPrisma() {
export let p: PrismaClient = undefined as unknown as PrismaClient
if (typeof window === 'undefined') {
// await delay(1000)
if (!prisma) {
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient()
} else {
if (!(global as any).prisma) {
;(global as any).prisma = new PrismaClient()
}
prisma = (global as any).prisma
if (process.env['NODE_ENV'] === 'production') {
p = new PrismaClient()
} else {
if (!global.prisma) {
global.prisma = new PrismaClient({
// log: [{ emit: 'stdout', level: 'query' }],
})
}
p = global.prisma
}
return prisma
}
export const prisma = p

View File

@@ -1,23 +1,16 @@
import { SplitMode } from '@prisma/client'
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),
@@ -28,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'],
})
}
@@ -38,36 +31,127 @@ export const groupFormSchema = z
export type GroupFormValues = z.infer<typeof groupFormSchema>
export const expenseFormSchema = z.object({
title: z
.string({ required_error: 'Please enter a title.' })
.min(2, 'Enter at least two characters.'),
amount: z
.union(
[
z.number(),
z.string().transform((value, ctx) => {
const valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber))
export const expenseFormSchema = z
.object({
expenseDate: z.coerce.date(),
title: z.string({ required_error: 'titleRequired' }).min(2, 'min2'),
category: z.coerce.number().default(0),
amount: z
.union(
[
z.number(),
z.string().transform((value, ctx) => {
const valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'invalidNumber',
})
return Math.round(valueAsNumber * 100)
}),
],
{ required_error: 'amountRequired' },
)
.refine((amount) => amount != 1, 'amountNotZero')
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
paidBy: z.string({ required_error: 'paidByRequired' }),
paidFor: z
.array(
z.object({
participant: z.string(),
shares: z.union([
z.number(),
z.string().transform((value, ctx) => {
const normalizedValue = value.replace(/,/g, '.')
const valueAsNumber = Number(normalizedValue)
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'invalidNumber',
})
return Math.round(valueAsNumber * 100)
}),
]),
}),
)
.min(1, 'paidForMin1')
.superRefine((paidFor, ctx) => {
let sum = 0
for (const { shares } of paidFor) {
sum += shares
if (shares < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid number.',
message: 'noZeroShares',
})
return Math.round(valueAsNumber * 100)
}
}
}),
splitMode: z
.enum<SplitMode, [SplitMode, ...SplitMode[]]>(
Object.values(SplitMode) as any,
)
.default('EVENLY'),
saveDefaultSplittingOptions: z.boolean(),
isReimbursement: z.boolean(),
documents: z
.array(
z.object({
id: z.string(),
url: z.string().url(),
width: z.number().int().min(1),
height: z.number().int().min(1),
}),
],
{ required_error: 'You must enter an amount.' },
)
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.')
.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.' }),
paidFor: z
.array(z.string())
.min(1, 'The expense must be paid for at least one participant.'),
isReimbursement: z.boolean(),
})
)
.default([]),
notes: z.string().optional(),
})
.superRefine((expense, ctx) => {
let sum = 0
for (const { shares } of expense.paidFor) {
sum +=
typeof shares === 'number' ? shares : Math.round(Number(shares) * 100)
}
switch (expense.splitMode) {
case 'EVENLY':
break // noop
case 'BY_SHARES':
break // noop
case 'BY_AMOUNT': {
if (sum !== expense.amount) {
const detail =
sum < expense.amount
? `${((expense.amount - sum) / 100).toFixed(2)} missing`
: `${((sum - expense.amount) / 100).toFixed(2)} surplus`
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'amountSum',
path: ['paidFor'],
})
}
break
}
case 'BY_PERCENTAGE': {
if (sum !== 10000) {
const detail =
sum < 10000
? `${((10000 - sum) / 100).toFixed(0)}% missing`
: `${((sum - 10000) / 100).toFixed(0)}% surplus`
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'percentageSum',
path: ['paidFor'],
})
}
break
}
}
})
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>
export type SplittingOptions = {
// Used for saving default splitting options in localStorage
splitMode: SplitMode
paidFor: ExpenseFormValues['paidFor'] | null
}

71
src/lib/totals.ts Normal file
View File

@@ -0,0 +1,71 @@
import { getGroupExpenses } from './api'
export function getTotalGroupSpending(
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
): number {
return expenses.reduce(
(total, expense) =>
expense.isReimbursement ? total : total + expense.amount,
0,
)
}
export function getTotalActiveUserPaidFor(
activeUserId: string | null,
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
): number {
return expenses.reduce(
(total, expense) =>
expense.paidBy.id === activeUserId && !expense.isReimbursement
? total + expense.amount
: total,
0,
)
}
export function getTotalActiveUserShare(
activeUserId: string | null,
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
): number {
let total = 0
expenses.forEach((expense) => {
if (expense.isReimbursement) return
const paidFors = expense.paidFor
const userPaidFor = paidFors.find(
(paidFor) => paidFor.participant.id === activeUserId,
)
if (!userPaidFor) {
// If the active user is not involved in the expense, skip it
return
}
switch (expense.splitMode) {
case 'EVENLY':
// Divide the total expense evenly among all participants
total += expense.amount / paidFors.length
break
case 'BY_AMOUNT':
// Directly add the user's share if the split mode is BY_AMOUNT
total += userPaidFor.shares
break
case 'BY_PERCENTAGE':
// Calculate the user's share based on their percentage of the total expense
total += (expense.amount * userPaidFor.shares) / 10000 // Assuming shares are out of 10000 for percentage
break
case 'BY_SHARES': // Calculate the user's share based on their shares relative to the total shares
{
const totalShares = paidFors.reduce(
(sum, paidFor) => sum + paidFor.shares,
0,
)
total += (expense.amount * userPaidFor.shares) / totalShares
break
}
}
})
return parseFloat(total.toFixed(2))
}

View File

@@ -1,10 +0,0 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -1,134 +0,0 @@
// @ts-nocheck
import { randomId } from '@/lib/api'
import { getPrisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client'
import { Client } from 'pg'
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
async function main() {
withClient(async (client) => {
const prisma = await getPrisma()
// console.log('Deleting all groups…')
// await prisma.group.deleteMany({})
const { rows: groupRows } = await client.query<{
id: string
name: string
currency: string
created_at: Date
}>('select id, name, currency, created_at from groups')
const existingGroups = (
await prisma.group.findMany({ select: { id: true } })
).map((group) => group.id)
for (const groupRow of groupRows) {
const participants: Prisma.ParticipantCreateManyInput[] = []
const expenses: Prisma.ExpenseCreateManyInput[] = []
const expenseParticipants: Prisma.ExpensePaidForCreateManyInput[] = []
const participantIdsMapping: Record<number, string> = {}
const expenseIdsMapping: Record<number, string> = {}
if (existingGroups.includes(groupRow.id)) {
console.log(`Group ${groupRow.id} already exists, skipping.`)
continue
}
const group: Prisma.GroupCreateInput = {
id: groupRow.id,
name: groupRow.name,
currency: groupRow.currency,
createdAt: groupRow.created_at,
}
const { rows: participantRows } = await client.query<{
id: number
created_at: Date
name: string
}>(
'select id, created_at, name from participants where group_id = $1::text',
[groupRow.id],
)
for (const participantRow of participantRows) {
const id = randomId()
participantIdsMapping[participantRow.id] = id
participants.push({
id,
groupId: groupRow.id,
name: participantRow.name,
})
}
const { rows: expenseRows } = await client.query<{
id: number
created_at: Date
description: string
amount: number
paid_by_participant_id: number
is_reimbursement: boolean
}>(
'select id, created_at, description, amount, paid_by_participant_id, is_reimbursement from expenses where group_id = $1::text and deleted_at is null',
[groupRow.id],
)
for (const expenseRow of expenseRows) {
const id = randomId()
expenseIdsMapping[expenseRow.id] = id
expenses.push({
id,
amount: Math.round(expenseRow.amount * 100),
groupId: groupRow.id,
title: expenseRow.description,
createdAt: expenseRow.created_at,
isReimbursement: expenseRow.is_reimbursement === true,
paidById: participantIdsMapping[expenseRow.paid_by_participant_id],
})
}
if (expenseRows.length > 0) {
const { rows: expenseParticipantRows } = await client.query<{
expense_id: number
participant_id: number
}>(
'select expense_id, participant_id from expense_participants where expense_id = any($1::int[]);',
[expenseRows.map((row) => row.id)],
)
for (const expenseParticipantRow of expenseParticipantRows) {
expenseParticipants.push({
expenseId: expenseIdsMapping[expenseParticipantRow.expense_id],
participantId:
participantIdsMapping[expenseParticipantRow.participant_id],
})
}
}
console.log('Creating group:', group)
await prisma.group.create({ data: group })
console.log('Creating participants:', participants)
await prisma.participant.createMany({ data: participants })
console.log('Creating expenses:', expenses)
await prisma.expense.createMany({ data: expenses })
console.log('Creating expenseParticipants:', expenseParticipants)
await prisma.expensePaidFor.createMany({ data: expenseParticipants })
}
})
}
async function withClient(fn: (client: Client) => void | Promise<void>) {
const client = new Client({
connectionString: process.env.OLD_POSTGRES_URL,
ssl: true,
})
await client.connect()
console.log('Connected.')
try {
await fn(client)
} finally {
await client.end()
console.log('Disconnected.')
}
}
main().catch(console.error)

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

@@ -0,0 +1,17 @@
import { initTRPC } from '@trpc/server'
import superjson from 'superjson'
// 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

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

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

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