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

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>
);
};