Add initial project setup with PocketBase integration, global state management, and UI components for gift tracking

This commit is contained in:
Norbert Maciaszek
2025-11-11 15:25:29 +01:00
parent 1fd7b56754
commit 60d0888a49
30 changed files with 1140 additions and 111 deletions

14
.prettierrc.json Normal file
View File

@@ -0,0 +1,14 @@
{
"singleQuote": true,
"trailingComma": "all",
"arrowParens": "always",
"bracketSpacing": true,
"semi": true,
"tabWidth": 2,
"useTabs": false,
"printWidth": 120,
"endOfLine": "lf",
"quoteProps": "as-needed",
"jsxSingleQuote": true,
"jsxBracketSameLine": true
}

43
package-lock.json generated
View File

@@ -9,8 +9,10 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"next": "16.0.1", "next": "16.0.1",
"pocketbase": "^0.26.3",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0" "react-dom": "19.2.0",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -1023,7 +1025,7 @@
"version": "19.2.2", "version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -1079,7 +1081,7 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/detect-libc": { "node_modules/detect-libc": {
@@ -1498,6 +1500,12 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/pocketbase": {
"version": "0.26.3",
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.3.tgz",
"integrity": "sha512-5deUKRoEczpxxuHzwr6/DHVmgbggxylEVig8CKN+MjvtYxPUqX/C6puU0yaR2yhTi8zrh7J9s7Ty+qBGwVzWOQ==",
"license": "MIT"
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -1691,6 +1699,35 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }

View File

@@ -8,17 +8,19 @@
"start": "next start" "start": "next start"
}, },
"dependencies": { "dependencies": {
"next": "16.0.1",
"pocketbase": "^0.26.3",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"next": "16.0.1" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"babel-plugin-react-compiler": "1.0.0", "@tailwindcss/postcss": "^4",
"typescript": "^5",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@tailwindcss/postcss": "^4", "babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4" "tailwindcss": "^4",
"typescript": "^5"
} }
} }

View File

@@ -1,26 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body { body {
background: var(--background); @apply bg-gray-50 text-gray-800 font-sans antialiased;
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
} }

View File

@@ -1,20 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import { Geist, Geist_Mono } from "next/font/google"; import './globals.css';
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: 'Gift Tracker',
description: "Generated by create next app", description: 'Track and manage your gifts',
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,11 +12,13 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang='en'>
<body <body className='font-sans'>
className={`${geistSans.variable} ${geistMono.variable} antialiased`} <div className='min-h-screen bg-gray-50'>
> <main className='pt-16 pb-8'>
{children} <div className='max-w-7xl mx-auto px-4 md:px-6 py-6 md:py-8'>{children}</div>
</main>
</div>
</body> </body>
</html> </html>
); );

View File

@@ -1,65 +1,20 @@
import Image from "next/image"; import { YearOverview } from '@/components/organisms/YearOverview';
import { YearList } from '@/components/atoms/YearList';
import { DB } from '@/lib/db';
import { GiftCard } from '@/components/molecules/GiftCard';
import { Heading } from '@/components/atoms/Heading';
export default async function Home() {
const recentGifts = await DB.getRecentGifts();
export default function Home() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <>
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <YearOverview />
<Image <YearList />
className="dark:invert" <Heading>Ostatnie prezenty</Heading>
src="/next.svg" {recentGifts.map((gift) => (
alt="Next.js logo" <GiftCard key={gift.id} {...gift} />
width={100} ))}
height={20} </>
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
); );
} }

32
src/app/store/global.tsx Normal file
View File

@@ -0,0 +1,32 @@
'use client';
import { useEffect } from 'react';
import { create } from 'zustand';
type Props = {
year: Year;
persons: Person[];
gifts: Gift[];
};
type State = Props & {
hydrate: (data: Partial<Props>) => void;
};
export const useGlobalStore = create<State>((set) => ({
year: {} as Year,
persons: [] as Person[],
gifts: [] as Gift[],
hydrate: (data: Partial<Props>) => set(data),
}));
export const GlobalStore = ({ children, year, persons, gifts }: Partial<Props> & { children: React.ReactNode }) => {
const hydrate = useGlobalStore((s) => s.hydrate);
useEffect(() => {
hydrate({ year, persons, gifts });
}, []);
return children;
};

View File

@@ -0,0 +1,24 @@
import { YearOverview } from '@/components/organisms/YearOverview';
import { PersonCard } from '@/components/molecules/PersonCard';
import { YearList } from '@/components/atoms/YearList';
import { DB } from '@/lib/db';
import { YearControls } from '@/components/organisms/YearControls';
import { GlobalStore } from '@/app/store/global';
export default async function YearPage({ params }: { params: Promise<{ year: string }> }) {
const { year } = await params;
const data = await DB.getYear(Number(year));
const persons = await DB.getPersons(data.id);
return (
<GlobalStore year={data} persons={persons} gifts={[]}>
<YearOverview year={Number(year)} />
<YearList />
<YearControls year={data.id} />
{persons.map((person) => (
<PersonCard key={person.name} {...person} />
))}
</GlobalStore>
);
}

View File

@@ -0,0 +1,11 @@
import { FC } from 'react';
type Props = {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
};
export const Badge: FC<Props> = ({ children, onClick, disabled }) => {
return <span className='px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-lg'>{children}</span>;
};

View File

@@ -0,0 +1,34 @@
import { FC } from 'react';
import Link from 'next/link';
type Props = {
children: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
variant?: keyof typeof variants;
href?: string;
};
export const Button: FC<Props> = ({ children, onClick, disabled, variant = 'primary', href }) => {
if (href) {
return (
<Link href={href} className={variants[variant]}>
{children}
</Link>
);
}
return (
<button className={variants[variant]} onClick={onClick} disabled={disabled}>
{children}
</button>
);
};
const variants = {
primary:
'px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 rounded-xl transition-all duration-200 ease-in-out disabled:opacity-70 disabled:cursor-not-allowed',
secondary:
'px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-100 rounded-xl transition-all duration-200 ease-in-out',
danger:
'px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-xl transition-all duration-200 ease-in-out disabled:opacity-70 disabled:cursor-not-allowed',
};

View File

@@ -0,0 +1,22 @@
import { FC } from 'react';
type Props = {
size?: 'small' | 'medium' | 'large';
children: React.ReactNode;
spacing?: 'none' | 'small' | 'medium' | 'large';
};
export const Heading: FC<Props> = ({ children, size = 'medium', spacing = 'medium' }) => {
const sizeClass = {
small: 'text-lg',
medium: 'text-2xl',
large: 'text-3xl',
};
const spacingClass = {
none: 'mb-0',
small: 'mb-2',
medium: 'mb-4',
large: 'mb-6',
};
return <h2 className={`${sizeClass[size]} font-semibold text-gray-800 ${spacingClass[spacing]}`}>{children}</h2>;
};

View File

@@ -0,0 +1,46 @@
'use client';
import { FC, ReactNode, useEffect } from 'react';
type Props = {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
};
export const Modal: FC<Props> = ({ isOpen, onClose, title, children, footer }) => {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4' onClick={onClose}>
<div
className='bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto'
onClick={(e) => e.stopPropagation()}>
<div className='sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-2xl'>
<h2 className='text-2xl font-semibold text-gray-800'>{title}</h2>
<button
onClick={onClose}
className='text-gray-400 hover:text-gray-600 transition-colors text-2xl leading-none'>
×
</button>
</div>
<div className='p-6'>{children}</div>
{footer && (
<div className='sticky bottom-0 bg-white border-t border-gray-200 px-6 py-4 rounded-b-2xl'>{footer}</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,17 @@
import { FC } from 'react';
type Props = {
title: string;
value: number | string;
description?: string;
};
export const StatsCard: FC<Props> = ({ title, value, description }) => {
return (
<div className='bg-white rounded-2xl shadow-sm hover:shadow-md p-4 md:p-6 transition-all duration-200 ease-in-out'>
<div className='text-sm text-gray-600 mb-1'>{title}</div>
<div className='text-3xl font-bold text-gray-800'>{value}</div>
{description && <div className='text-xs text-gray-500 mt-2'>{description}</div>}
</div>
);
};

View File

@@ -0,0 +1,14 @@
import { FC } from 'react';
type Props = {
budget: number;
};
export const YearBudget: FC<Props> = ({ budget }) => {
return (
<div className='mb-6'>
<h2 className='text-2xl font-semibold text-gray-800 mb-4'>Budżet</h2>
<p className='text-gray-600'>{budget}</p>
</div>
);
};

View File

@@ -0,0 +1,21 @@
import { FC } from 'react';
type Props = {
year: number;
onCopyPeople?: () => void;
isCopying?: boolean;
};
export const YearHeader: FC<Props> = ({ year, onCopyPeople, isCopying = false }) => {
return (
<div className='flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-4'>
<h1 className='text-2xl font-bold text-gray-800'>Rok {year}</h1>
<button
className='px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 rounded-xl transition-all duration-200 ease-in-out disabled:opacity-70 disabled:cursor-not-allowed'
onClick={onCopyPeople}
disabled={isCopying}>
{isCopying ? 'Kopiowanie...' : 'Skopiuj osoby z poprzedniego roku'}
</button>
</div>
);
};

View File

@@ -0,0 +1,21 @@
import { DB } from '@/lib/db';
import { Button } from '../Button';
export const YearList = async () => {
const years = await DB.getYears();
return (
<div className='mb-6 flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between'>
<div className='flex items-center gap-2'>
<Button variant='secondary' href='/'>
Wszystkie lata
</Button>
{years.map((year) => (
<Button key={year.id} href={`/year/${year.year}`} variant='secondary'>
{year.year}
</Button>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import { FC } from 'react';
type Props = {
notes: string;
};
export const YearNotes: FC<Props> = ({ notes }) => {
return (
<div className='mb-6'>
<h2 className='text-2xl font-semibold text-gray-800 mb-4'>Notes</h2>
<p className='text-gray-600'>{notes}</p>
</div>
);
};

View File

@@ -0,0 +1,22 @@
import { Heading } from '@/components/atoms/Heading';
import { FC } from 'react';
type Props = {
title: string;
description?: string;
backgroundColor?: string;
onClick: () => void;
};
export const ActionCard: FC<Props> = ({ title, description, backgroundColor = 'bg-white', onClick }) => {
return (
<div
className={`${backgroundColor} inline-block rounded-2xl shadow-sm hover:shadow-md p-4 md:p-6 transition-all duration-200 ease-in-out cursor-pointer`}
onClick={onClick}>
<Heading size='small' spacing='none'>
{title}
</Heading>
{description && <p className='text-sm text-gray-600'>{description}</p>}
</div>
);
};

View File

@@ -0,0 +1,91 @@
'use client';
import { FC, useState } from 'react';
import { formatCurrency } from '@/helpers/formatCurrency';
import { formatStatus } from '@/helpers/formatStatus';
import { GiftModal } from '../GiftModal';
import { useGlobalStore } from '@/app/store/global';
import { DB } from '@/lib/db';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/atoms/Button';
type Props = {
hideDetails?: boolean;
editable?: boolean;
personId?: string;
};
export const GiftCard: FC<Gift & Props> = ({ hideDetails = false, editable = false, personId, ...gift }) => {
const [isOpen, setIsOpen] = useState(false);
const year = useGlobalStore((s) => s.year);
const router = useRouter();
const { title, description, cost, status, created, expand } = gift;
const bgByStatus = {
planned: 'bg-gray-100',
decided: 'bg-blue-100',
bought: 'bg-green-100',
wrapped: 'bg-purple-100',
};
const handleDelete = async () => {
await DB.deleteGift(gift.id);
router.refresh();
};
return (
<>
<div
className={`rounded-2xl shadow-sm p-4 my-6 md:p-6 transition-all duration-200 ease-in-out ${
bgByStatus[status]
} ${editable ? 'cursor-pointer hover:shadow-md ' : ''}`}
onClick={editable ? () => setIsOpen(true) : undefined}>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
<div className='flex-1'>
<div className='flex items-center justify-between gap-3 mb-2 pb-2 border-b border-gray-200'>
<h3 className='text-lg font-semibold text-gray-800'>{title}</h3>
</div>
{description && <p className='text-base text-gray-600 mb-3'>{description}</p>}
<div className='flex flex-wrap items-center gap-4 text-sm text-gray-500'>
<span>Data dodania: {new Date(created).toLocaleDateString()}</span>
<span></span>
<span>Koszt: {formatCurrency(cost)}</span>
{!hideDetails && (
<>
<span></span>
<span>Dla: {expand?.person.name}</span>
</>
)}
<span></span>
<span>Status: {formatStatus(status)}</span>
{editable && (
<div className='ml-auto'>
<Button
variant='danger'
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}>
Usuń
</Button>
</div>
)}
</div>
</div>
</div>
</div>
<GiftModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
yearId={year.id}
personId={personId}
gift={gift}
onSave={async (data) => {
await DB.updateGift(gift.id, data);
router.refresh();
}}
/>
</>
);
};

View File

@@ -0,0 +1,184 @@
'use client';
import { FC, useState, useEffect } from 'react';
import { Modal } from '@/components/atoms/Modal';
import { Button } from '@/components/atoms/Button';
import { formatStatus } from '@/helpers/formatStatus';
type Props = {
isOpen: boolean;
onClose: () => void;
gift?: Gift;
yearId: string;
personId?: string;
onSave: (data: {
title: string;
description: string;
link: string;
cost: number;
imageUrl: string;
status: 'planned' | 'decided' | 'bought' | 'wrapped';
year: string;
person: string;
}) => Promise<void>;
};
export const GiftModal: FC<Props> = ({ isOpen, onClose, gift, yearId, personId, onSave }) => {
const [title, setTitle] = useState(gift?.title || '');
const [description, setDescription] = useState(gift?.description || '');
const [link, setLink] = useState(gift?.link || '');
const [cost, setCost] = useState(gift?.cost || 0);
const [imageUrl, setImageUrl] = useState(gift?.imageUrl || '');
const [status, setStatus] = useState<Gift['status']>(gift?.status || 'planned');
const [selectedPersonId, setSelectedPersonId] = useState(personId || '');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (gift) {
setTitle(gift.title);
setDescription(gift.description || '');
setLink(gift.link || '');
setCost(gift.cost);
setImageUrl(gift.imageUrl || '');
setStatus(gift.status);
setSelectedPersonId(gift.person);
} else {
setTitle('');
setDescription('');
setLink('');
setCost(0);
setImageUrl('');
setStatus('planned');
setSelectedPersonId(personId || '');
}
}, [gift, personId, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await onSave({
title,
description,
link,
cost,
imageUrl,
status,
year: yearId,
person: selectedPersonId,
});
onClose();
} catch (error) {
console.error('Error saving gift:', error);
} finally {
setIsLoading(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={gift ? 'Edytuj prezent' : 'Dodaj nowy prezent'}
footer={
<div className='flex justify-end gap-3'>
<Button variant='secondary' onClick={onClose} disabled={isLoading}>
Anuluj
</Button>
<Button variant='primary' onClick={handleSubmit} disabled={isLoading || !title.trim() || !selectedPersonId}>
{isLoading ? 'Zapisywanie...' : 'Zapisz'}
</Button>
</div>
}>
<form onSubmit={handleSubmit} className='space-y-4'>
<div>
<label htmlFor='title' className='block text-sm font-medium text-gray-700 mb-2'>
Tytuł *
</label>
<input
id='title'
type='text'
value={title}
onChange={(e) => setTitle(e.target.value)}
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor='description' className='block text-sm font-medium text-gray-700 mb-2'>
Opis
</label>
<textarea
id='description'
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all resize-none'
disabled={isLoading}
/>
</div>
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
<div>
<label htmlFor='cost' className='block text-sm font-medium text-gray-700 mb-2'>
Koszt (PLN) *
</label>
<input
id='cost'
type='number'
step='0.01'
min='0'
value={cost}
onChange={(e) => setCost(parseFloat(e.target.value) || 0)}
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor='status' className='block text-sm font-medium text-gray-700 mb-2'>
Status *
</label>
<select
id='status'
value={status}
onChange={(e) => setStatus(e.target.value as typeof status)}
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
required
disabled={isLoading}>
<option value='planned'>{formatStatus('planned')} </option>
<option value='decided'>{formatStatus('decided')}</option>
<option value='bought'>{formatStatus('bought')}</option>
<option value='wrapped'>{formatStatus('wrapped')}</option>
</select>
</div>
</div>
<div>
<label htmlFor='link' className='block text-sm font-medium text-gray-700 mb-2'>
Link
</label>
<input
id='link'
type='url'
value={link}
onChange={(e) => setLink(e.target.value)}
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
disabled={isLoading}
/>
</div>
<div>
<label htmlFor='imageUrl' className='block text-sm font-medium text-gray-700 mb-2'>
URL obrazu
</label>
<input
id='imageUrl'
type='url'
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
disabled={isLoading}
/>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,71 @@
'use client';
import { Button } from '@/components/atoms/Button';
import { FC, useState } from 'react';
import { PersonModal } from '../PersonModal';
import { DB } from '@/lib/db';
import { useGlobalStore } from '@/app/store/global';
import { useRouter } from 'next/navigation';
import { GiftModal } from '../GiftModal';
type Props = {
person: Person;
};
export const PersonCardEdit: FC<Props> = ({ person }) => {
const [isOpen, setIsOpen] = useState(false);
const year = useGlobalStore((s) => s.year);
const router = useRouter();
const handleDelete = async () => {
await DB.deletePerson(person.id);
router.refresh();
};
return (
<>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
<Button variant='primary' onClick={() => setIsOpen(true)}>
Edytuj
</Button>
<Button variant='secondary' onClick={handleDelete}>
Usuń
</Button>
</div>
<PersonModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
year={year.id}
person={person}
onSave={async (data) => {
await DB.updatePerson(person.id, data);
router.refresh();
}}
/>
</>
);
};
export const GiftCardAdd: FC<Props> = ({ person }) => {
const [isOpen, setIsOpen] = useState(false);
const year = useGlobalStore((s) => s.year);
const router = useRouter();
return (
<>
<Button variant='secondary' onClick={() => setIsOpen(true)}>
+
</Button>
<GiftModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
yearId={year.id}
personId={person.id}
onSave={async (data) => {
await DB.createGift(data);
router.refresh();
}}
/>
</>
);
};

View File

@@ -0,0 +1,36 @@
import { Heading } from '@/components/atoms/Heading';
import { formatCurrency } from '@/helpers/formatCurrency';
import { FC } from 'react';
import { GiftCard } from '../GiftCard';
import { GiftCardAdd, PersonCardEdit } from './client';
export const PersonCard: FC<Person> = (person) => {
const { name, notes, expand } = person;
const { gifts } = expand;
return (
<div className='bg-white rounded-2xl shadow-sm hover:shadow-md p-4 md:p-6 transition-all duration-200 ease-in-out mt-6'>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-2 mb-6 border-b border-gray-200'>
<div className='flex-1'>
<Heading size='large'>{name}</Heading>
</div>
<PersonCardEdit person={person} />
</div>
{notes && <p className='text-gray-600'>{notes}</p>}
<div className='flex flex-wrap items-center gap-4 text-sm text-gray-500'>
<span>Ilość prezentów: {gifts?.length || 0}</span>
<span></span>
<span>Koszt: {formatCurrency(gifts?.reduce((acc, gift) => acc + gift.cost, 0) || 0)}</span>
</div>
<div className='mt-8 mb-4 flex items-center gap-4'>
<Heading size='medium' spacing='none'>
Prezenty
</Heading>
<GiftCardAdd person={person} />
</div>
{gifts?.map((gift) => (
<GiftCard key={gift.id} {...gift} hideDetails editable personId={person.id} />
))}
</div>
);
};

View File

@@ -0,0 +1,88 @@
'use client';
import { FC, useState, useEffect } from 'react';
import { Modal } from '@/components/atoms/Modal';
import { Button } from '@/components/atoms/Button';
type Props = {
isOpen: boolean;
onClose: () => void;
year: string;
person?: Person;
onSave: (data: { name: string; notes: string; year: string }) => Promise<void>;
};
export const PersonModal: FC<Props> = ({ isOpen, onClose, year, person, onSave }) => {
const [name, setName] = useState('');
const [notes, setNotes] = useState('');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (person) {
setName(person.name);
setNotes(person.notes || '');
} else {
setName('');
setNotes('');
}
}, [person, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await onSave({ name, notes, year });
onClose();
} catch (error) {
console.error('Error saving person:', error);
} finally {
setIsLoading(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={person ? 'Edytuj osobę' : 'Dodaj nową osobę'}
footer={
<div className='flex justify-end gap-3'>
<Button variant='secondary' onClick={onClose} disabled={isLoading}>
Anuluj
</Button>
<Button variant='primary' onClick={handleSubmit} disabled={isLoading || !name.trim()}>
{isLoading ? 'Zapisywanie...' : 'Zapisz'}
</Button>
</div>
}>
<form onSubmit={handleSubmit} className='space-y-4'>
<div>
<label htmlFor='name' className='block text-sm font-medium text-gray-700 mb-2'>
Imię i nazwisko *
</label>
<input
id='name'
type='text'
value={name}
onChange={(e) => setName(e.target.value)}
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor='notes' className='block text-sm font-medium text-gray-700 mb-2'>
Notatki
</label>
<textarea
id='notes'
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all resize-none'
disabled={isLoading}
/>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,31 @@
'use client';
import { FC, useState } from 'react';
import { ActionCard } from '../../molecules/ActionCard';
import { PersonModal } from '../../molecules/PersonModal';
import { DB } from '@/lib/db';
import { useRouter } from 'next/navigation';
type Props = {
year: string;
};
export const YearControls: FC<Props> = ({ year }) => {
const [modalOpen, setModalOpen] = useState<'addPerson' | null>(null);
const router = useRouter();
return (
<>
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 my-8'>
<ActionCard title='Dodaj nową osobę' onClick={() => setModalOpen('addPerson')} backgroundColor='bg-green-50' />
</div>
<PersonModal
isOpen={modalOpen === 'addPerson'}
year={year}
onClose={() => setModalOpen(null)}
onSave={async (data) => {
await DB.createPerson(data);
router.refresh();
}}
/>
</>
);
};

View File

@@ -0,0 +1,51 @@
import { StatsCard } from '@/components/atoms/StatsCard';
import { DB } from '@/lib/db';
import { formatCurrency } from '@/helpers/formatCurrency';
import { FC } from 'react';
import { Heading } from '@/components/atoms/Heading';
type Props = {
year?: number;
};
export const YearOverview: FC<Props> = async ({ year }) => {
const currentYear = new Date().getFullYear();
const data = await DB.getYear(year ?? currentYear);
if (!data) {
return null;
}
const gifts = await Promise.all(
data.gifts.map(async (gift) => {
return await DB.getGift(gift);
}),
);
const totalCost = gifts.reduce((acc, gift) => acc + gift.cost, 0);
const averageCost = totalCost / gifts.length;
return (
<div className='mb-6 md:mb-8'>
<Heading size='large'>Podsumowanie roku {data.year}</Heading>
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4'>
<StatsCard title='Planowane prezenty' value={gifts.length} />
<StatsCard
title='Łączny koszt'
value={formatCurrency(totalCost)}
description={`Średnio: ${formatCurrency(averageCost)}`}
/>
<StatsCard
title='Status: Do kupienia'
value={gifts.filter((gift) => gift.status === 'decided').length}
description='Prezenty do kupienia'
/>
<StatsCard
title='Status: Kupione'
value={gifts.filter((gift) => gift.status === 'bought').length}
description='Prezenty które zostały już kupione'
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
export const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('pl-PL', {
style: 'currency',
currency: 'PLN',
useGrouping: true,
}).format(amount);
};

View File

@@ -0,0 +1,12 @@
export const formatStatus = (status: Gift['status']) => {
switch (status) {
case 'planned':
return 'Planowane';
case 'decided':
return 'Do kupienia';
case 'bought':
return 'Kupione';
case 'wrapped':
return 'Gotowe';
}
};

106
src/lib/db/index.ts Normal file
View File

@@ -0,0 +1,106 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase('https://db.maciaszek.ovh');
export const DB = {
getYear: async (year: number): Promise<Year> => {
return await pb.collection('gifts_year').getFirstListItem(`year = ${year}`, {
expand: 'gifts.person',
fields:
'*,expand.gifts.*,expand.gifts.expand.person.name,expand.gifts.expand.person.id,expand.gifts.expand.person.notes',
});
},
getYears: async (): Promise<DB.Year[]> => {
return await pb.collection('gifts_year').getFullList();
},
getPerson: async (name: string): Promise<Person> => {
return await pb.collection('gifts_person').getFirstListItem(`name = "${name}"`);
},
createPerson: async (data: { name: string; notes: string; year: string }): Promise<Person> => {
return await pb.collection('gifts_person').create({ ...data, years: data.year });
},
updatePerson: async (id: string, data: { name: string; notes: string }): Promise<Person> => {
return await pb.collection('gifts_person').update(id, data);
},
deletePerson: async (id: string): Promise<void> => {
await pb.collection('gifts_person').delete(id);
},
getPersons: async (yearId: string): Promise<Person[]> => {
return await pb.collection('gifts_person').getFullList({
filter: `years ~ "${yearId}"`,
expand: 'gifts,years',
fields: '*,expand.gifts.*,expand.years.id,expand.years.year',
});
},
getGift: async (id: string): Promise<Gift> => {
return await pb.collection('gifts_items').getOne(id, {
expand: 'year,person',
fields: '*,expand.year.year,expand.person.name,expand.year.id,expand.person.id',
});
},
createGift: async (data: {
title: string;
description: string;
link: string;
cost: number;
imageUrl: string;
status: 'planned' | 'decided' | 'bought' | 'wrapped';
year: string;
person: string;
}): Promise<Gift> => {
const gift = await pb.collection('gifts_items').create(data);
console.log(gift);
pb.collection('gifts_person').update(data.person, {
'gifts+': gift.id,
});
pb.collection('gifts_year').update(data.year, {
'gifts+': gift.id,
});
return gift as unknown as Gift;
},
updateGift: async (
id: string,
data: {
title: string;
description: string;
link: string;
cost: number;
imageUrl: string;
status: 'planned' | 'decided' | 'bought' | 'wrapped';
year: string;
person: string;
},
): Promise<Gift> => {
return await pb.collection('gifts_items').update(id, data);
},
deleteGift: async (id: string): Promise<void> => {
const gift = await pb.collection('gifts_items').getOne(id);
await pb.collection('gifts_items').delete(id);
await pb.collection('gifts_person').update(gift.person, {
'gifts-': id,
});
await pb.collection('gifts_year').update(gift.year, {
'gifts-': id,
});
},
getGifts: async (yearId: string): Promise<Gift[]> => {
return await pb.collection('gifts_items').getFullList({
filter: `year = "${yearId}"`,
expand: 'year,person',
fields: '*,expand.year.year,expand.person.name,expand.year.id,expand.person.id',
});
},
getRecentGifts: async (): Promise<Gift[]> => {
return await pb.collection('gifts_items').getFullList({
sort: 'created',
limit: 3,
expand: 'year,person',
fields: '*,expand.year.year,expand.person.name,expand.year.id,expand.person.id',
});
},
};

35
src/lib/db/types.d.ts vendored Normal file
View File

@@ -0,0 +1,35 @@
namespace DB {
type Year = {
id: string;
year: number;
notes: string;
budgetLimit: number;
created: Date;
updated: Date;
gifts: string[];
};
type Person = {
id: string;
name: string;
notes: string;
created: Date;
updated: Date;
gifts: string[];
};
type Gift = {
id: string;
title: string;
description: string;
link: string;
cost: number;
imageUrl: string;
status: 'planned' | 'decided' | 'bought' | 'wrapped';
created: Date;
updated: Date;
year: string;
person: string;
};
}

61
src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,61 @@
type Year = {
id: string;
year: number;
notes: string;
budgetLimit: number;
created: Date;
updated: Date;
gifts: string[];
expand: {
gifts: Gift[] & {
expand: {
person: {
id: string;
name: string;
notes: string;
};
};
};
};
};
type Person = {
id: string;
name: string;
notes: string;
created: Date;
updated: Date;
gifts: string[];
expand: {
gifts?: Gift[];
year: {
id: string;
year: number;
};
};
};
type Gift = {
id: string;
title: string;
description: string;
link: string;
cost: number;
imageUrl: string;
status: 'planned' | 'decided' | 'bought' | 'wrapped';
created: Date;
updated: Date;
year: string;
person: string;
expand: {
year: {
id: string;
year: number;
};
person: {
id: string;
name: string;
};
};
};