├── app ├── favicon.ico ├── page.tsx ├── actions │ ├── getUserBalance.ts │ ├── getTransactions.tsx │ ├── deleteTransaction.ts │ ├── getIncomeExpense.ts │ └── addTransaction.ts ├── layout.tsx └── globals.css ├── public ├── screenshot.png ├── vercel.svg └── next.svg ├── next.config.mjs ├── lib ├── utils.ts ├── db.ts └── checkUser.ts ├── .env-example ├── types └── Transaction.ts ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20240620171129_user_transaction_create │ │ └── migration.sql └── schema.prisma ├── middleware.ts ├── components ├── Guest.tsx ├── Balance.tsx ├── Header.tsx ├── IncomeExpense.tsx ├── TransactionList.tsx ├── TransactionItem.tsx └── AddTransaction.tsx ├── .gitignore ├── tsconfig.json ├── package.json └── README.md /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradtraversy/expense-tracker-nextjs/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradtraversy/expense-tracker-nextjs/HEAD/public/screenshot.png -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | export function addCommas(x: number): string { 2 | return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); 3 | } 4 | -------------------------------------------------------------------------------- /.env-example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=REPLACE_WITH_YOURS 2 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=REPLACE_WITH_YOURS 3 | CLERK_SECRET_KEY=REPLACE_WITH_YOURS -------------------------------------------------------------------------------- /types/Transaction.ts: -------------------------------------------------------------------------------- 1 | export interface Transaction { 2 | id: string; 3 | text: string; 4 | amount: number; 5 | userId: string; 6 | createdAt: Date; 7 | } 8 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { clerkMiddleware } from '@clerk/nextjs/server'; 2 | 3 | export default clerkMiddleware(); 4 | 5 | export const config = { 6 | matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'], 7 | }; 8 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined; 5 | } 6 | 7 | export const db = globalThis.prisma || new PrismaClient(); 8 | 9 | if (process.env.NODE_ENV !== 'production') { 10 | globalThis.prisma = db; 11 | } 12 | -------------------------------------------------------------------------------- /components/Guest.tsx: -------------------------------------------------------------------------------- 1 | import { SignInButton } from '@clerk/nextjs'; 2 | 3 | const Guest = () => { 4 | return ( 5 |
6 |

Welcome

7 |

Please sign in to manage your transactions

8 | 9 |
10 | ); 11 | }; 12 | 13 | export default Guest; 14 | -------------------------------------------------------------------------------- /components/Balance.tsx: -------------------------------------------------------------------------------- 1 | import getUserBalance from '@/app/actions/getUserBalance'; 2 | import { addCommas } from '@/lib/utils'; 3 | 4 | const Balance = async () => { 5 | const { balance } = await getUserBalance(); 6 | 7 | return ( 8 | <> 9 |

Your Balance

10 |

${addCommas(Number(balance?.toFixed(2) ?? 0))}

11 | 12 | ); 13 | }; 14 | 15 | export default Balance; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { SignInButton, SignedIn, SignedOut, UserButton } from '@clerk/nextjs'; 2 | import { checkUser } from '@/lib/checkUser'; 3 | 4 | const Header = async () => { 5 | const user = await checkUser(); 6 | 7 | return ( 8 | 21 | ); 22 | }; 23 | 24 | export default Header; 25 | -------------------------------------------------------------------------------- /components/IncomeExpense.tsx: -------------------------------------------------------------------------------- 1 | import getIncomeExpense from '@/app/actions/getIncomeExpense'; 2 | import { addCommas } from '@/lib/utils'; 3 | 4 | const IncomeExpense = async () => { 5 | const { income, expense } = await getIncomeExpense(); 6 | 7 | return ( 8 |
9 |
10 |

Income

11 |

${addCommas(Number(income?.toFixed(2)))}

12 |
13 |
14 |

Expense

15 |

${addCommas(Number(expense?.toFixed(2)))}

16 |
17 |
18 | ); 19 | }; 20 | 21 | export default IncomeExpense; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expense-tracker-nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@clerk/nextjs": "^5.1.6", 14 | "@prisma/client": "^5.15.1", 15 | "next": "14.2.4", 16 | "react": "^18", 17 | "react-dom": "^18", 18 | "react-toastify": "^10.0.5" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^20", 22 | "@types/react": "^18", 23 | "@types/react-dom": "^18", 24 | "prisma": "^5.15.1", 25 | "typescript": "^5" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from '@clerk/nextjs/server'; 2 | import Guest from '@/components/Guest'; 3 | import AddTransaction from '@/components/AddTransaction'; 4 | import Balance from '@/components/Balance'; 5 | import IncomeExpense from '@/components/IncomeExpense'; 6 | import TransactionList from '@/components/TransactionList'; 7 | 8 | const HomePage = async () => { 9 | const user = await currentUser(); 10 | 11 | if (!user) { 12 | return ; 13 | } 14 | 15 | return ( 16 |
17 |

Welcome, {user.firstName}

18 | 19 | 20 | 21 | 22 |
23 | ); 24 | }; 25 | 26 | export default HomePage; 27 | -------------------------------------------------------------------------------- /app/actions/getUserBalance.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import { db } from '@/lib/db'; 3 | import { auth } from '@clerk/nextjs/server'; 4 | 5 | async function getUserBalance(): Promise<{ 6 | balance?: number; 7 | error?: string; 8 | }> { 9 | const { userId } = auth(); 10 | 11 | if (!userId) { 12 | return { error: 'User not found' }; 13 | } 14 | 15 | try { 16 | const transactions = await db.transaction.findMany({ 17 | where: { userId }, 18 | }); 19 | 20 | const balance = transactions.reduce( 21 | (sum, transaction) => sum + transaction.amount, 22 | 0 23 | ); 24 | 25 | return { balance }; 26 | } catch (error) { 27 | return { error: 'Database error' }; 28 | } 29 | } 30 | 31 | export default getUserBalance; 32 | -------------------------------------------------------------------------------- /components/TransactionList.tsx: -------------------------------------------------------------------------------- 1 | import getTransactions from '@/app/actions/getTransactions'; 2 | import TransactionItem from './TransactionItem'; 3 | import { Transaction } from '@/types/Transaction'; 4 | 5 | const TransactionList = async () => { 6 | const { transactions, error } = await getTransactions(); 7 | 8 | if (error) { 9 | return

{error}

; 10 | } 11 | 12 | return ( 13 | <> 14 |

History

15 | 21 | 22 | ); 23 | }; 24 | 25 | export default TransactionList; 26 | -------------------------------------------------------------------------------- /app/actions/getTransactions.tsx: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import { db } from '@/lib/db'; 3 | import { auth } from '@clerk/nextjs/server'; 4 | import { Transaction } from '@/types/Transaction'; 5 | 6 | async function getTransactions(): Promise<{ 7 | transactions?: Transaction[]; 8 | error?: string; 9 | }> { 10 | const { userId } = auth(); 11 | 12 | if (!userId) { 13 | return { error: 'User not found' }; 14 | } 15 | 16 | try { 17 | const transactions = await db.transaction.findMany({ 18 | where: { userId }, 19 | orderBy: { 20 | createdAt: 'desc', 21 | }, 22 | }); 23 | 24 | return { transactions }; 25 | } catch (error) { 26 | return { error: 'Database error' }; 27 | } 28 | } 29 | 30 | export default getTransactions; 31 | -------------------------------------------------------------------------------- /app/actions/deleteTransaction.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import { db } from '@/lib/db'; 3 | import { auth } from '@clerk/nextjs/server'; 4 | import { revalidatePath } from 'next/cache'; 5 | 6 | async function deleteTransaction(transactionId: string): Promise<{ 7 | message?: string; 8 | error?: string; 9 | }> { 10 | const { userId } = auth(); 11 | 12 | if (!userId) { 13 | return { error: 'User not found' }; 14 | } 15 | 16 | try { 17 | await db.transaction.delete({ 18 | where: { 19 | id: transactionId, 20 | userId, 21 | }, 22 | }); 23 | 24 | revalidatePath('/'); 25 | 26 | return { message: 'Transaction deleted' }; 27 | } catch (error) { 28 | return { error: 'Database error' }; 29 | } 30 | } 31 | 32 | export default deleteTransaction; 33 | -------------------------------------------------------------------------------- /lib/checkUser.ts: -------------------------------------------------------------------------------- 1 | import { currentUser } from '@clerk/nextjs/server'; 2 | import { db } from '@/lib/db'; 3 | 4 | export const checkUser = async () => { 5 | const user = await currentUser(); 6 | 7 | // Check for current logged in clerk user 8 | if (!user) { 9 | return null; 10 | } 11 | 12 | // Check if the user is already in the database 13 | const loggedInUser = await db.user.findUnique({ 14 | where: { 15 | clerkUserId: user.id, 16 | }, 17 | }); 18 | 19 | // If user is in database, return user 20 | if (loggedInUser) { 21 | return loggedInUser; 22 | } 23 | 24 | // If not in database, create new user 25 | const newUser = await db.user.create({ 26 | data: { 27 | clerkUserId: user.id, 28 | name: `${user.firstName} ${user.lastName}`, 29 | imageUrl: user.imageUrl, 30 | email: user.emailAddresses[0].emailAddress, 31 | }, 32 | }); 33 | 34 | return newUser; 35 | }; 36 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Roboto } from 'next/font/google'; 3 | import './globals.css'; 4 | import { ClerkProvider } from '@clerk/nextjs'; 5 | import Header from '@/components/Header'; 6 | import { ToastContainer } from 'react-toastify'; 7 | import 'react-toastify/dist/ReactToastify.css'; 8 | 9 | const roboto = Roboto({ weight: '400', subsets: ['latin'] }); 10 | 11 | export const metadata: Metadata = { 12 | title: 'Expense Tracker', 13 | description: 'Track your expenses and create a budget', 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: Readonly<{ 19 | children: React.ReactNode; 20 | }>) { 21 | return ( 22 | 23 | 24 | 25 |
26 |
{children}
27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/actions/getIncomeExpense.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import { db } from '@/lib/db'; 3 | import { auth } from '@clerk/nextjs/server'; 4 | 5 | async function getIncomeExpense(): Promise<{ 6 | income?: number; 7 | expense?: number; 8 | error?: string; 9 | }> { 10 | const { userId } = auth(); 11 | 12 | if (!userId) { 13 | return { error: 'User not found' }; 14 | } 15 | 16 | try { 17 | const transactions = await db.transaction.findMany({ 18 | where: { userId }, 19 | }); 20 | 21 | const amounts = transactions.map((transaction) => transaction.amount); 22 | 23 | const income = amounts 24 | .filter((item) => item > 0) 25 | .reduce((acc, item) => acc + item, 0); 26 | 27 | const expense = amounts 28 | .filter((item) => item < 0) 29 | .reduce((acc, item) => acc + item, 0); 30 | 31 | return { income, expense: Math.abs(expense) }; 32 | } catch (error) { 33 | return { error: 'Database error' }; 34 | } 35 | } 36 | 37 | export default getIncomeExpense; 38 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "postgresql" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | model User { 17 | id String @id @default(uuid()) 18 | clerkUserId String @unique 19 | email String @unique 20 | name String? 21 | imageUrl String? 22 | createdAt DateTime @default(now()) 23 | updatedAt DateTime @updatedAt 24 | transactions Transaction[] 25 | } 26 | 27 | model Transaction { 28 | id String @id @default(uuid()) 29 | text String 30 | amount Float 31 | // Relation to user 32 | userId String 33 | user User @relation(fields: [userId], references: [clerkUserId], onDelete: Cascade) 34 | createdAt DateTime @default(now()) 35 | @@index([userId]) 36 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Expense Tracker (Next.js, TypeScript, Neon & Clerk) 2 | 3 | Application for tracking income and expenses. It uses Next.js with [Neon](https://fyi.neon.tech/traversy) to persist data and [Clerk](https://go.clerk.com/BsG2XQJ) for authentication. 4 | 5 | [Watch The Tutorial](https://www.youtube.com/watch?v=I6DCo5RwHBE) 6 | 7 | [Try Demo](https://traversydemos.dev) 8 | 9 |
10 | 11 |
12 | 13 | ## Usage 14 | 15 | ### Install dependencies: 16 | 17 | ```bash 18 | npm install 19 | ``` 20 | 21 | ### Add Environment Variables: 22 | 23 | Rename the `.env.example` file to `.env.local` and add the following values: 24 | 25 | - `DATABASE_URL`: Your db string from https://neon.tech 26 | - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`: Your Clerk public frontend API key from https://dashboard.clerk.dev/settings/api-keys 27 | - `CLERK_SECRET_KEY`: Your Clerk secret key from https://dashboard.clerk.dev/settings/api-keys 28 | 29 | Run the development server: 30 | 31 | ```bash 32 | npm run dev 33 | ``` 34 | 35 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 36 | -------------------------------------------------------------------------------- /prisma/migrations/20240620171129_user_transaction_create/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "clerkUserId" TEXT NOT NULL, 5 | "email" TEXT NOT NULL, 6 | "name" TEXT, 7 | "imageUrl" TEXT, 8 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "updatedAt" TIMESTAMP(3) NOT NULL, 10 | 11 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateTable 15 | CREATE TABLE "Transaction" ( 16 | "id" TEXT NOT NULL, 17 | "text" TEXT NOT NULL, 18 | "amount" DOUBLE PRECISION NOT NULL, 19 | "userId" TEXT NOT NULL, 20 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 21 | 22 | CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id") 23 | ); 24 | 25 | -- CreateIndex 26 | CREATE UNIQUE INDEX "User_clerkUserId_key" ON "User"("clerkUserId"); 27 | 28 | -- CreateIndex 29 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 30 | 31 | -- CreateIndex 32 | CREATE INDEX "Transaction_userId_idx" ON "Transaction"("userId"); 33 | 34 | -- AddForeignKey 35 | ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("clerkUserId") ON DELETE CASCADE ON UPDATE CASCADE; 36 | -------------------------------------------------------------------------------- /components/TransactionItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Transaction } from '@/types/Transaction'; 3 | import { addCommas } from '@/lib/utils'; 4 | import { toast } from 'react-toastify'; 5 | import deleteTransaction from '@/app/actions/deleteTransaction'; 6 | 7 | const TransactionItem = ({ transaction }: { transaction: Transaction }) => { 8 | const sign = transaction.amount < 0 ? '-' : '+'; 9 | 10 | const handleDeleteTransaction = async (transactionId: string) => { 11 | const confirmed = window.confirm( 12 | 'Are you sure you want to delete this transaction?' 13 | ); 14 | 15 | if (!confirmed) return; 16 | 17 | const { message, error } = await deleteTransaction(transactionId); 18 | 19 | if (error) { 20 | toast.error(error); 21 | } 22 | 23 | toast.success(message); 24 | }; 25 | 26 | return ( 27 |
  • 28 | {transaction.text} 29 | 30 | {sign}${addCommas(Math.abs(transaction.amount))} 31 | 32 | 38 |
  • 39 | ); 40 | }; 41 | 42 | export default TransactionItem; 43 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/actions/addTransaction.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import { auth } from '@clerk/nextjs/server'; 3 | import { db } from '@/lib/db'; 4 | import { revalidatePath } from 'next/cache'; 5 | 6 | interface TransactionData { 7 | text: string; 8 | amount: number; 9 | } 10 | 11 | interface TransactionResult { 12 | data?: TransactionData; 13 | error?: string; 14 | } 15 | 16 | async function addTransaction(formData: FormData): Promise { 17 | const textValue = formData.get('text'); 18 | const amountValue = formData.get('amount'); 19 | 20 | // Check for input values 21 | if (!textValue || textValue === '' || !amountValue) { 22 | return { error: 'Text or amount is missing' }; 23 | } 24 | 25 | const text: string = textValue.toString(); // Ensure text is a string 26 | const amount: number = parseFloat(amountValue.toString()); // Parse amount as number 27 | 28 | // Get logged in user 29 | const { userId } = auth(); 30 | 31 | // Check for user 32 | if (!userId) { 33 | return { error: 'User not found' }; 34 | } 35 | 36 | try { 37 | const transactionData: TransactionData = await db.transaction.create({ 38 | data: { 39 | text, 40 | amount, 41 | userId, 42 | }, 43 | }); 44 | 45 | revalidatePath('/'); 46 | 47 | return { data: transactionData }; 48 | } catch (error) { 49 | return { error: 'Transaction not added' }; 50 | } 51 | } 52 | 53 | export default addTransaction; 54 | -------------------------------------------------------------------------------- /components/AddTransaction.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useRef } from 'react'; 3 | import addTransaction from '@/app/actions/addTransaction'; 4 | import { toast } from 'react-toastify'; 5 | 6 | const AddTransaction = () => { 7 | const formRef = useRef(null); 8 | 9 | const clientAction = async (formData: FormData) => { 10 | const { data, error } = await addTransaction(formData); 11 | 12 | if (error) { 13 | toast.error(error); 14 | } else { 15 | toast.success('Transaction added'); 16 | formRef.current?.reset(); 17 | } 18 | }; 19 | 20 | return ( 21 | <> 22 |

    Add transaction

    23 |
    24 |
    25 | 26 | 32 |
    33 |
    34 | 37 | 44 |
    45 | 46 |
    47 | 48 | ); 49 | }; 50 | 51 | export default AddTransaction; 52 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); 3 | } 4 | 5 | * { 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | background-color: #f7f7f7; 11 | min-height: 100vh; 12 | margin: 0; 13 | font-family: 'Lato', sans-serif; 14 | } 15 | 16 | .container { 17 | margin: 30px auto; 18 | width: 350px; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | 25 | .navbar { 26 | background: #000; 27 | color: #fff; 28 | } 29 | 30 | .navbar a { 31 | color: #fff; 32 | } 33 | 34 | .navbar button, 35 | .guest button { 36 | border: 0; 37 | border-radius: 5px; 38 | background: rebeccapurple; 39 | color: #fff; 40 | padding: 0.5rem 2rem; 41 | cursor: pointer; 42 | } 43 | 44 | .navbar button:hover { 45 | opacity: 0.9; 46 | } 47 | 48 | .navbar-container { 49 | display: flex; 50 | justify-content: space-between; 51 | align-items: center; 52 | max-width: 900px; 53 | margin: 0 auto; 54 | padding: 0 2rem; 55 | } 56 | 57 | .navbar .cl-button { 58 | background: transparent; 59 | border: 0; 60 | } 61 | 62 | h1 { 63 | letter-spacing: 1px; 64 | margin: 0; 65 | } 66 | 67 | h3 { 68 | border-bottom: 1px solid #bbb; 69 | padding-bottom: 10px; 70 | margin: 40px 0 10px; 71 | } 72 | 73 | h4 { 74 | margin: 0; 75 | text-transform: uppercase; 76 | } 77 | 78 | .guest { 79 | text-align: center; 80 | } 81 | 82 | .error { 83 | background: red; 84 | color: #fff; 85 | padding: 3px; 86 | } 87 | 88 | .inc-exp-container { 89 | background-color: #fff; 90 | box-shadow: var(--box-shadow); 91 | padding: 20px; 92 | display: flex; 93 | justify-content: space-between; 94 | margin: 20px 0; 95 | } 96 | 97 | .inc-exp-container > div { 98 | flex: 1; 99 | text-align: center; 100 | } 101 | 102 | .inc-exp-container > div:first-of-type { 103 | border-right: 1px solid #dedede; 104 | } 105 | 106 | .money { 107 | font-size: 20px; 108 | letter-spacing: 1px; 109 | margin: 5px 0; 110 | } 111 | 112 | .money.plus { 113 | color: #2ecc71; 114 | } 115 | 116 | .money.minus { 117 | color: #c0392b; 118 | } 119 | 120 | label { 121 | display: inline-block; 122 | margin: 10px 0; 123 | } 124 | 125 | input[type='text'], 126 | input[type='number'] { 127 | border: 1px solid #dedede; 128 | border-radius: 2px; 129 | display: block; 130 | font-size: 16px; 131 | padding: 10px; 132 | width: 100%; 133 | } 134 | 135 | .btn { 136 | cursor: pointer; 137 | background-color: #9c88ff; 138 | box-shadow: var(--box-shadow); 139 | color: #fff; 140 | border: 0; 141 | display: block; 142 | font-size: 16px; 143 | margin: 10px 0 30px; 144 | padding: 10px; 145 | width: 100%; 146 | } 147 | 148 | .btn:focus, 149 | .delete-btn:focus { 150 | outline: 0; 151 | } 152 | 153 | .list { 154 | list-style-type: none; 155 | padding: 0; 156 | margin-bottom: 40px; 157 | } 158 | 159 | .list li { 160 | background-color: #fff; 161 | box-shadow: var(--box-shadow); 162 | color: #333; 163 | display: flex; 164 | justify-content: space-between; 165 | position: relative; 166 | padding: 10px; 167 | margin: 10px 0; 168 | } 169 | 170 | .list li.plus { 171 | border-right: 5px solid #2ecc71; 172 | } 173 | 174 | .list li.minus { 175 | border-right: 5px solid #c0392b; 176 | } 177 | 178 | .delete-btn { 179 | cursor: pointer; 180 | background-color: #e74c3c; 181 | border: 0; 182 | color: #fff; 183 | font-size: 20px; 184 | line-height: 20px; 185 | padding: 2px 5px; 186 | position: absolute; 187 | top: 50%; 188 | left: 0; 189 | transform: translate(-100%, -50%); 190 | opacity: 0; 191 | transition: opacity 0.3s ease; 192 | } 193 | 194 | .list li:hover .delete-btn { 195 | opacity: 1; 196 | } 197 | --------------------------------------------------------------------------------