Compare commits
10 Commits
fix/backen
...
feature/i1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b23097b18 | ||
| db41d4a246 | |||
|
|
547a452097 | ||
| 3d7501f028 | |||
|
|
7c658706ea | ||
| 1c9c21d5a7 | |||
|
|
89f75e6d40 | ||
| e69f53114d | |||
|
|
793f0c3422 | ||
| d99002dc3c |
@@ -25,6 +25,20 @@ export async function saveUploadedImage(
|
|||||||
return { imageUrl: relative };
|
return { imageUrl: relative };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveUploadedAvatar(
|
||||||
|
userId: string,
|
||||||
|
mime: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
): Promise<{ imageUrl: string }> {
|
||||||
|
const ext = MIME_TO_EXT[mime];
|
||||||
|
if (!ext) throw new ValidationError('Unsupported image type');
|
||||||
|
const filename = `${userId}-${nanoid(8)}.${ext}`;
|
||||||
|
const relative = `/uploads/avatar/${filename}`;
|
||||||
|
const absPath = resolve(env.UPLOADS_DIR, 'avatar', filename);
|
||||||
|
await writeFile(absPath, buffer);
|
||||||
|
return { imageUrl: relative };
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteLocalImageIfAny(imageUrl: string | null): Promise<void> {
|
export async function deleteLocalImageIfAny(imageUrl: string | null): Promise<void> {
|
||||||
if (!imageUrl) return;
|
if (!imageUrl) return;
|
||||||
if (!imageUrl.startsWith('/uploads/')) return;
|
if (!imageUrl.startsWith('/uploads/')) return;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { updateProfileSchema } from '@family-wishlist/shared';
|
import { updateProfileSchema } from '@family-wishlist/shared';
|
||||||
import { ConflictError, NotFoundError } from '../../utils/errors.js';
|
import { ConflictError, NotFoundError, ValidationError } from '../../utils/errors.js';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { deleteLocalImageIfAny, saveUploadedAvatar } from '../images/storage.service.js';
|
||||||
|
|
||||||
|
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
export default async function profileRoutes(app: FastifyInstance) {
|
export default async function profileRoutes(app: FastifyInstance) {
|
||||||
app.addHook('preHandler', app.authenticate);
|
app.addHook('preHandler', app.authenticate);
|
||||||
@@ -27,4 +30,25 @@ export default async function profileRoutes(app: FastifyInstance) {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/avatar', async (request) => {
|
||||||
|
const current = await app.prisma.user.findUnique({ where: { id: request.user.id } });
|
||||||
|
if (!current) throw new NotFoundError('Profile');
|
||||||
|
|
||||||
|
const data = await request.file();
|
||||||
|
if (!data) throw new ValidationError('No file uploaded');
|
||||||
|
|
||||||
|
const buffer = await data.toBuffer();
|
||||||
|
if (buffer.byteLength > MAX_AVATAR_BYTES) {
|
||||||
|
throw new ValidationError('Avatar must be 2 MB or less');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imageUrl } = await saveUploadedAvatar(request.user.id, data.mimetype, buffer);
|
||||||
|
await deleteLocalImageIfAny(current.avatarUrl);
|
||||||
|
|
||||||
|
return app.prisma.user.update({
|
||||||
|
where: { id: request.user.id },
|
||||||
|
data: { avatarUrl: imageUrl },
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"rootDir": "./src",
|
"rootDir": "./",
|
||||||
"outDir": "./dist"
|
"outDir": "./dist"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*", "prisma/seed.ts"],
|
||||||
"exclude": ["**/*.test.ts", "dist"]
|
"exclude": ["**/*.test.ts", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { router } from './routes';
|
import { router } from './routes';
|
||||||
import { useAuthStore } from './features/auth/authStore';
|
import { useAuthStore } from './features/auth/authStore';
|
||||||
|
import { I18nProvider } from './i18n/i18n';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -24,11 +25,13 @@ function AuthBoot({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
<I18nProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthBoot>
|
<AuthBoot>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</AuthBoot>
|
</AuthBoot>
|
||||||
<Toaster position="top-center" richColors closeButton />
|
<Toaster position="top-center" richColors closeButton />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
41
apps/frontend/src/components/LanguageSwitcher.tsx
Normal file
41
apps/frontend/src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Languages } from 'lucide-react';
|
||||||
|
import { useI18n, type Language } from '@/i18n/i18n';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
const languages: Array<{ value: Language; label: string }> = [
|
||||||
|
{ value: 'ru', label: 'RU' },
|
||||||
|
{ value: 'en', label: 'EN' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LanguageSwitcher({ className }: { className?: string }) {
|
||||||
|
const { language, setLanguage, t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-md border border-border bg-surface/90 p-1 text-xs shadow-card',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={t('language.switch')}
|
||||||
|
>
|
||||||
|
<Languages className="ml-1 h-3.5 w-3.5 text-muted" aria-hidden />
|
||||||
|
{languages.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLanguage(item.value)}
|
||||||
|
className={cn(
|
||||||
|
'rounded px-2 py-1 font-semibold transition-colors',
|
||||||
|
language === item.value
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted hover:bg-surface-muted hover:text-ink',
|
||||||
|
)}
|
||||||
|
aria-pressed={language === item.value}
|
||||||
|
title={item.value === 'ru' ? t('language.ru') : t('language.en')}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { Gift } from 'lucide-react';
|
import { Gift } from 'lucide-react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { FRONTEND_VERSION } from '@/lib/version';
|
import { FRONTEND_VERSION } from '@/lib/version';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
interface VersionInfo {
|
interface VersionInfo {
|
||||||
backend: string;
|
backend: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
const { t } = useI18n();
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['version'],
|
queryKey: ['version'],
|
||||||
queryFn: () => api.get<VersionInfo>('/api/version'),
|
queryFn: () => api.get<VersionInfo>('/api/version'),
|
||||||
@@ -19,12 +21,12 @@ export function Footer() {
|
|||||||
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row">
|
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Gift className="h-4 w-4" aria-hidden />
|
<Gift className="h-4 w-4" aria-hidden />
|
||||||
<span className="font-display text-sm">Family Wishlist</span>
|
<span className="font-display text-sm">{t('app.name')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span>frontend v{FRONTEND_VERSION}</span>
|
<span>{t('footer.frontend', { version: FRONTEND_VERSION })}</span>
|
||||||
<span className="opacity-50">·</span>
|
<span className="opacity-50">·</span>
|
||||||
<span>backend v{data?.backend ?? '...'}</span>
|
<span>{t('footer.backend', { version: data?.backend ?? '...' })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { Archive, Gift, LogOut, Sparkles, Trash2, UserCog, CheckCircle2 } from 'lucide-react';
|
import { Archive, Gift, LogOut, Sparkles, Trash2, UserCog, CheckCircle2 } from 'lucide-react';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
import { useAuthStore } from '@/features/auth/authStore';
|
import { useAuthStore } from '@/features/auth/authStore';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
import { useI18n, type TranslationKey } from '@/i18n/i18n';
|
||||||
|
import { LanguageSwitcher } from '../LanguageSwitcher';
|
||||||
|
|
||||||
|
type NavIcon = ComponentType<{ className?: string }>;
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ to: '/', label: 'Active', icon: Sparkles, end: true },
|
{ to: '/', label: 'header.active', icon: Sparkles, end: true },
|
||||||
{ to: '/completed', label: 'Fulfilled', icon: CheckCircle2 },
|
{ to: '/completed', label: 'header.fulfilled', icon: CheckCircle2 },
|
||||||
{ to: '/archive', label: 'Archive', icon: Archive },
|
{ to: '/archive', label: 'header.archive', icon: Archive },
|
||||||
{ to: '/trash', label: 'Trash', icon: Trash2 },
|
{ to: '/trash', label: 'header.trash', icon: Trash2 },
|
||||||
];
|
] satisfies Array<{
|
||||||
|
to: string;
|
||||||
|
label: TranslationKey;
|
||||||
|
icon: NavIcon;
|
||||||
|
end?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const logout = useAuthStore((s) => s.logout);
|
const logout = useAuthStore((s) => s.logout);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
@@ -26,9 +37,9 @@ export function Header() {
|
|||||||
<Gift className="h-4 w-4" />
|
<Gift className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-display text-lg leading-tight">Family Wishlist</div>
|
<div className="font-display text-lg leading-tight">{t('app.name')}</div>
|
||||||
<div className="text-xs text-muted">
|
<div className="text-xs text-muted">
|
||||||
signed in as <span className="font-medium text-ink">{user.displayName}</span>
|
{t('header.signedInAs', { name: user.displayName })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -47,20 +58,21 @@ export function Header() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<l.icon className="h-4 w-4" />
|
<l.icon className="h-4 w-4" />
|
||||||
{l.label}
|
{t(l.label)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<LanguageSwitcher />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate('/settings')}
|
onClick={() => navigate('/settings')}
|
||||||
title="Profile settings"
|
title={t('header.profileSettings')}
|
||||||
>
|
>
|
||||||
<UserCog className="h-4 w-4" />
|
<UserCog className="h-4 w-4" />
|
||||||
Profile
|
{t('header.profile')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -70,7 +82,7 @@ export function Header() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
Log out
|
{t('header.logout')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useAuthStore } from '@/features/auth/authStore';
|
import { useAuthStore } from '@/features/auth/authStore';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||||
const { user, status } = useAuthStore();
|
const { user, status } = useAuthStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { t } = useI18n();
|
||||||
if (status !== 'ready') {
|
if (status !== 'ready') {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[50vh] items-center justify-center text-muted">Loading...</div>
|
<div className="flex min-h-[50vh] items-center justify-center text-muted">
|
||||||
|
{t('protected.loading')}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Check, Sparkles, Archive, Trash2 } from 'lucide-react';
|
import { Check, Sparkles, Archive, Trash2 } from 'lucide-react';
|
||||||
import type { Wish } from '@family-wishlist/shared';
|
import type { Wish } from '@family-wishlist/shared';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
wish: Wish & { isNewForOwner?: boolean };
|
wish: Wish & { isNewForOwner?: boolean };
|
||||||
@@ -9,6 +10,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WishBadges({ wish, view, className }: Props) {
|
export function WishBadges({ wish, view, className }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const badges: JSX.Element[] = [];
|
const badges: JSX.Element[] = [];
|
||||||
|
|
||||||
const isNew =
|
const isNew =
|
||||||
@@ -17,7 +19,7 @@ export function WishBadges({ wish, view, className }: Props) {
|
|||||||
badges.push(
|
badges.push(
|
||||||
<span className="wish-badge wish-badge--new" key="new">
|
<span className="wish-badge wish-badge--new" key="new">
|
||||||
<Sparkles className="h-3 w-3" aria-hidden />
|
<Sparkles className="h-3 w-3" aria-hidden />
|
||||||
new
|
{t('wish.badge.new')}
|
||||||
</span>,
|
</span>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -25,7 +27,7 @@ export function WishBadges({ wish, view, className }: Props) {
|
|||||||
badges.push(
|
badges.push(
|
||||||
<span className="wish-badge wish-badge--completed" key="done">
|
<span className="wish-badge wish-badge--completed" key="done">
|
||||||
<Check className="h-3 w-3" aria-hidden />
|
<Check className="h-3 w-3" aria-hidden />
|
||||||
fulfilled
|
{t('wish.badge.fulfilled')}
|
||||||
</span>,
|
</span>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,7 +35,7 @@ export function WishBadges({ wish, view, className }: Props) {
|
|||||||
badges.push(
|
badges.push(
|
||||||
<span className="wish-badge wish-badge--archived" key="arch">
|
<span className="wish-badge wish-badge--archived" key="arch">
|
||||||
<Archive className="h-3 w-3" aria-hidden />
|
<Archive className="h-3 w-3" aria-hidden />
|
||||||
archived
|
{t('wish.badge.archived')}
|
||||||
</span>,
|
</span>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,7 +43,7 @@ export function WishBadges({ wish, view, className }: Props) {
|
|||||||
badges.push(
|
badges.push(
|
||||||
<span className="wish-badge wish-badge--deleted" key="del">
|
<span className="wish-badge wish-badge--deleted" key="del">
|
||||||
<Trash2 className="h-3 w-3" aria-hidden />
|
<Trash2 className="h-3 w-3" aria-hidden />
|
||||||
trash
|
{t('wish.badge.trash')}
|
||||||
</span>,
|
</span>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { WishBadges } from '../WishBadges/WishBadges';
|
|||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { formatPrice } from '@/lib/format';
|
import { formatPrice } from '@/lib/format';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
export type WishCardView = 'owner' | 'guest';
|
export type WishCardView = 'owner' | 'guest';
|
||||||
|
|
||||||
@@ -49,39 +50,40 @@ function WishCardInner({
|
|||||||
footer,
|
footer,
|
||||||
}: WishCardProps) {
|
}: WishCardProps) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const { locale, t } = useI18n();
|
||||||
const completed = wish.status === 'COMPLETED';
|
const completed = wish.status === 'COMPLETED';
|
||||||
const priceLabel = formatPrice(wish.price, wish.currency);
|
const priceLabel = formatPrice(wish.price, wish.currency, locale);
|
||||||
const imageSrc = wish.imageUrl ?? '/default-gift.svg';
|
const imageSrc = wish.imageUrl ?? '/default-gift.svg';
|
||||||
|
|
||||||
const actions: WishCardAction[] = [];
|
const actions: WishCardAction[] = [];
|
||||||
if (view === 'owner') {
|
if (view === 'owner') {
|
||||||
if (wish.status === 'ACTIVE') {
|
if (wish.status === 'ACTIVE') {
|
||||||
if (onEdit) actions.push({ key: 'edit', icon: Pencil, label: 'Edit', onClick: onEdit });
|
if (onEdit) actions.push({ key: 'edit', icon: Pencil, label: t('wish.action.edit'), onClick: onEdit });
|
||||||
if (onComplete)
|
if (onComplete)
|
||||||
actions.push({
|
actions.push({
|
||||||
key: 'complete',
|
key: 'complete',
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
label: 'Mark fulfilled',
|
label: t('wish.action.complete'),
|
||||||
onClick: onComplete,
|
onClick: onComplete,
|
||||||
});
|
});
|
||||||
if (onArchive)
|
if (onArchive)
|
||||||
actions.push({ key: 'archive', icon: Archive, label: 'Archive', onClick: onArchive });
|
actions.push({ key: 'archive', icon: Archive, label: t('wish.action.archive'), onClick: onArchive });
|
||||||
if (onDelete)
|
if (onDelete)
|
||||||
actions.push({
|
actions.push({
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
label: 'Delete',
|
label: t('wish.action.delete'),
|
||||||
onClick: onDelete,
|
onClick: onDelete,
|
||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
} else if (wish.status === 'ARCHIVED') {
|
} else if (wish.status === 'ARCHIVED') {
|
||||||
if (onRestore)
|
if (onRestore)
|
||||||
actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore });
|
actions.push({ key: 'restore', icon: RotateCcw, label: t('wish.action.restore'), onClick: onRestore });
|
||||||
if (onDelete)
|
if (onDelete)
|
||||||
actions.push({
|
actions.push({
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
label: 'Delete',
|
label: t('wish.action.delete'),
|
||||||
onClick: onDelete,
|
onClick: onDelete,
|
||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
@@ -90,20 +92,20 @@ function WishCardInner({
|
|||||||
actions.push({
|
actions.push({
|
||||||
key: 'duplicate',
|
key: 'duplicate',
|
||||||
icon: Copy,
|
icon: Copy,
|
||||||
label: 'Create copy as new',
|
label: t('wish.action.duplicate'),
|
||||||
onClick: onDuplicate,
|
onClick: onDuplicate,
|
||||||
});
|
});
|
||||||
if (onDelete)
|
if (onDelete)
|
||||||
actions.push({
|
actions.push({
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
label: 'Delete',
|
label: t('wish.action.delete'),
|
||||||
onClick: onDelete,
|
onClick: onDelete,
|
||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
} else if (wish.status === 'DELETED') {
|
} else if (wish.status === 'DELETED') {
|
||||||
if (onRestore)
|
if (onRestore)
|
||||||
actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore });
|
actions.push({ key: 'restore', icon: RotateCcw, label: t('wish.action.restore'), onClick: onRestore });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +130,7 @@ function WishCardInner({
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Actions"
|
aria-label={t('wish.actions')}
|
||||||
onClick={() => setMenuOpen((v) => !v)}
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
@@ -177,7 +179,7 @@ function WishCardInner({
|
|||||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary-600"
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary-600"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
open link
|
{t('wish.openLink')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
useUpdateWish,
|
useUpdateWish,
|
||||||
useUploadWishImage,
|
useUploadWishImage,
|
||||||
} from '@/features/wishes/wishes.hooks';
|
} from '@/features/wishes/wishes.hooks';
|
||||||
|
import { translateValidation, useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -24,6 +25,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WishForm({ open, mode, initial, onClose }: Props) {
|
export function WishForm({ open, mode, initial, onClose }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const create = useCreateWish();
|
const create = useCreateWish();
|
||||||
const update = useUpdateWish();
|
const update = useUpdateWish();
|
||||||
const upload = useUploadWishImage();
|
const upload = useUploadWishImage();
|
||||||
@@ -78,59 +80,74 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={mode === 'create' ? 'Add a wish' : 'Edit wish'}
|
title={mode === 'create' ? t('wishForm.addTitle') : t('wishForm.editTitle')}
|
||||||
description={
|
description={
|
||||||
mode === 'create'
|
mode === 'create'
|
||||||
? 'Tell us what you want. A link helps us grab a preview image automatically.'
|
? t('wishForm.addDescription')
|
||||||
: 'Update the details of your wish.'
|
: t('wishForm.editDescription')
|
||||||
}
|
}
|
||||||
size="lg"
|
size="lg"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" onClick={onClose}>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="wish-form" disabled={isSubmitting}>
|
<Button type="submit" form="wish-form" disabled={isSubmitting}>
|
||||||
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{mode === 'create' ? 'Add wish' : 'Save'}
|
{mode === 'create' ? t('wishForm.addSubmit') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form id="wish-form" className="grid gap-4" onSubmit={submit}>
|
<form id="wish-form" className="grid gap-4" onSubmit={submit}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<Label htmlFor="title">Title</Label>
|
<Label htmlFor="title">{t('wishForm.title')}</Label>
|
||||||
<Input id="title" placeholder="Moka pot, size 3" {...register('title')} />
|
<Input id="title" placeholder={t('wishForm.titlePlaceholder')} {...register('title')} />
|
||||||
{errors.title && <span className="field__error">{errors.title.message}</span>}
|
{errors.title && (
|
||||||
|
<span className="field__error">{translateValidation(t, errors.title.message)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-[1fr_auto]">
|
<div className="grid gap-4 sm:grid-cols-[1fr_auto]">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<Label htmlFor="price">Price (optional)</Label>
|
<Label htmlFor="price">{t('wishForm.price')}</Label>
|
||||||
<Input id="price" placeholder="e.g. 2490" inputMode="decimal" {...register('price')} />
|
<Input
|
||||||
{errors.price && <span className="field__error">{errors.price.message as string}</span>}
|
id="price"
|
||||||
|
placeholder={t('wishForm.pricePlaceholder')}
|
||||||
|
inputMode="decimal"
|
||||||
|
{...register('price')}
|
||||||
|
/>
|
||||||
|
{errors.price && (
|
||||||
|
<span className="field__error">
|
||||||
|
{translateValidation(t, errors.price.message as string)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<Label htmlFor="currency">Currency</Label>
|
<Label htmlFor="currency">{t('wishForm.currency')}</Label>
|
||||||
<Input id="currency" maxLength={3} className="uppercase w-24" {...register('currency')} />
|
<Input id="currency" maxLength={3} className="uppercase w-24" {...register('currency')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<Label htmlFor="url">Link (optional)</Label>
|
<Label htmlFor="url">{t('wishForm.link')}</Label>
|
||||||
<Input id="url" type="url" placeholder="https://..." {...register('url')} />
|
<Input id="url" type="url" placeholder="https://..." {...register('url')} />
|
||||||
{errors.url && <span className="field__error">{errors.url.message as string}</span>}
|
{errors.url && (
|
||||||
|
<span className="field__error">
|
||||||
|
{translateValidation(t, errors.url.message as string)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
We will try to pull a preview image from the link after saving.
|
{t('wishForm.linkHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<Label htmlFor="comment">Comment (optional)</Label>
|
<Label htmlFor="comment">{t('wishForm.comment')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="comment"
|
id="comment"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Size / color / notes..."
|
placeholder={t('wishForm.commentPlaceholder')}
|
||||||
{...register('comment')}
|
{...register('comment')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +157,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
|
|||||||
<section className="mt-6 rounded-md border border-border bg-surface-muted p-4">
|
<section className="mt-6 rounded-md border border-border bg-surface-muted p-4">
|
||||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-ink">
|
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-ink">
|
||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
Image
|
{t('wishForm.image')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -165,7 +182,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Upload custom
|
{t('wishForm.uploadCustom')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -178,7 +195,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<RefreshCcw className="h-4 w-4" />
|
<RefreshCcw className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Refresh from link
|
{t('wishForm.refreshFromLink')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -187,7 +204,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
|
|||||||
disabled={resetImage.isPending}
|
disabled={resetImage.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Reset to default
|
{t('wishForm.resetImage')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { CreateWishInput, UpdateWishInput } from '@family-wishlist/shared';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { wishesApi, type OwnerStatus } from './wishes.api';
|
import { wishesApi, type OwnerStatus } from './wishes.api';
|
||||||
import { ApiError } from '@/lib/api';
|
import { ApiError } from '@/lib/api';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
const LIST_KEY = (status: OwnerStatus) => ['wishes', status] as const;
|
const LIST_KEY = (status: OwnerStatus) => ['wishes', status] as const;
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ function invalidateAll(client: ReturnType<typeof useQueryClient>): void {
|
|||||||
void client.invalidateQueries({ queryKey: ['wishes'] });
|
void client.invalidateQueries({ queryKey: ['wishes'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toastError(err: unknown, fallback = 'Something went wrong'): void {
|
function toastError(err: unknown, fallback: string): void {
|
||||||
if (err instanceof ApiError) toast.error(err.message);
|
if (err instanceof ApiError) toast.error(err.message);
|
||||||
else toast.error(fallback);
|
else toast.error(fallback);
|
||||||
}
|
}
|
||||||
@@ -25,121 +26,131 @@ export function useWishes(status: OwnerStatus) {
|
|||||||
|
|
||||||
export function useCreateWish() {
|
export function useCreateWish() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (input: CreateWishInput) => wishesApi.create(input),
|
mutationFn: (input: CreateWishInput) => wishesApi.create(input),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll(client);
|
invalidateAll(client);
|
||||||
toast.success('Wish added');
|
toast.success(t('toast.wishAdded'));
|
||||||
},
|
},
|
||||||
onError: (err) => toastError(err),
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateWish() {
|
export function useUpdateWish() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (vars: { id: string; input: UpdateWishInput }) =>
|
mutationFn: (vars: { id: string; input: UpdateWishInput }) =>
|
||||||
wishesApi.update(vars.id, vars.input),
|
wishesApi.update(vars.id, vars.input),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll(client);
|
invalidateAll(client);
|
||||||
toast.success('Saved');
|
toast.success(t('toast.saved'));
|
||||||
},
|
},
|
||||||
onError: (err) => toastError(err),
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useArchiveWish() {
|
export function useArchiveWish() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => wishesApi.archive(id),
|
mutationFn: (id: string) => wishesApi.archive(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll(client);
|
invalidateAll(client);
|
||||||
toast.success('Moved to archive');
|
toast.success(t('toast.archived'));
|
||||||
},
|
},
|
||||||
onError: (err) => toastError(err),
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCompleteWish() {
|
export function useCompleteWish() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => wishesApi.complete(id),
|
mutationFn: (id: string) => wishesApi.complete(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll(client);
|
invalidateAll(client);
|
||||||
toast.success('Marked as fulfilled');
|
toast.success(t('toast.fulfilled'));
|
||||||
},
|
},
|
||||||
onError: (err) => toastError(err),
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteWish() {
|
export function useDeleteWish() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => wishesApi.remove(id),
|
mutationFn: (id: string) => wishesApi.remove(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll(client);
|
invalidateAll(client);
|
||||||
toast.success('Moved to trash (30 days to restore)');
|
toast.success(t('toast.deleted'));
|
||||||
},
|
},
|
||||||
onError: (err) => toastError(err),
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRestoreWish() {
|
export function useRestoreWish() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => wishesApi.restore(id),
|
mutationFn: (id: string) => wishesApi.restore(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll(client);
|
invalidateAll(client);
|
||||||
toast.success('Restored');
|
toast.success(t('toast.restored'));
|
||||||
},
|
},
|
||||||
onError: (err) => toastError(err),
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDuplicateWish() {
|
export function useDuplicateWish() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => wishesApi.duplicate(id),
|
mutationFn: (id: string) => wishesApi.duplicate(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll(client);
|
invalidateAll(client);
|
||||||
toast.success('New wish created from the fulfilled one');
|
toast.success(t('toast.duplicated'));
|
||||||
},
|
},
|
||||||
onError: (err) => toastError(err),
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUploadWishImage() {
|
export function useUploadWishImage() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (vars: { id: string; file: File }) => wishesApi.uploadImage(vars.id, vars.file),
|
mutationFn: (vars: { id: string; file: File }) => wishesApi.uploadImage(vars.id, vars.file),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll(client);
|
invalidateAll(client);
|
||||||
toast.success('Image updated');
|
toast.success(t('toast.imageUpdated'));
|
||||||
},
|
},
|
||||||
onError: (err) => toastError(err),
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRefreshOg() {
|
export function useRefreshOg() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => wishesApi.refreshOg(id),
|
mutationFn: (id: string) => wishesApi.refreshOg(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll(client);
|
invalidateAll(client);
|
||||||
toast.success('Image refreshed from link');
|
toast.success(t('toast.imageRefreshed'));
|
||||||
},
|
},
|
||||||
onError: (err) => toastError(err, 'Could not fetch image from link'),
|
onError: (err) => toastError(err, t('toast.imageFetchFailed')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useResetWishImage() {
|
export function useResetWishImage() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => wishesApi.deleteImage(id),
|
mutationFn: (id: string) => wishesApi.deleteImage(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll(client);
|
invalidateAll(client);
|
||||||
toast.success('Reset to default image');
|
toast.success(t('toast.imageReset'));
|
||||||
},
|
},
|
||||||
onError: (err) => toastError(err),
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
335
apps/frontend/src/i18n/i18n.tsx
Normal file
335
apps/frontend/src/i18n/i18n.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export type Language = 'ru' | 'en';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'family-wishlist-language';
|
||||||
|
const FALLBACK_LANGUAGE: Language = 'ru';
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
ru: {
|
||||||
|
'app.name': 'Family Wishlist',
|
||||||
|
'language.ru': 'Русский',
|
||||||
|
'language.en': 'English',
|
||||||
|
'language.switch': 'Язык',
|
||||||
|
'common.loading': 'Загрузка...',
|
||||||
|
'common.cancel': 'Отмена',
|
||||||
|
'common.save': 'Сохранить',
|
||||||
|
'common.saveChanges': 'Сохранить изменения',
|
||||||
|
'common.backHome': 'На главную',
|
||||||
|
'common.days.one': '{count} день',
|
||||||
|
'common.days.few': '{count} дня',
|
||||||
|
'common.days.many': '{count} дней',
|
||||||
|
'footer.frontend': 'frontend v{version}',
|
||||||
|
'footer.backend': 'backend v{version}',
|
||||||
|
'header.active': 'Активные',
|
||||||
|
'header.fulfilled': 'Исполненные',
|
||||||
|
'header.archive': 'Архив',
|
||||||
|
'header.trash': 'Корзина',
|
||||||
|
'header.signedInAs': 'вошли как {name}',
|
||||||
|
'header.profileSettings': 'Настройки профиля',
|
||||||
|
'header.profile': 'Профиль',
|
||||||
|
'header.logout': 'Выйти',
|
||||||
|
'login.title': 'С возвращением',
|
||||||
|
'login.description': 'Войдите, чтобы управлять списком желаний. Доступы задаются на сервере.',
|
||||||
|
'login.username': 'Логин',
|
||||||
|
'login.password': 'Пароль',
|
||||||
|
'login.submit': 'Войти',
|
||||||
|
'login.failed': 'Не удалось войти',
|
||||||
|
'dashboard.title': 'Ваш список желаний',
|
||||||
|
'dashboard.description': 'Добавляйте вещи, о которых мечтаете. Публичная страница доступна по адресу ',
|
||||||
|
'dashboard.addWish': 'Добавить желание',
|
||||||
|
'dashboard.emptyTitle': 'Желаний пока нет',
|
||||||
|
'dashboard.emptyText': 'Начните с того, что вам действительно хочется получить.',
|
||||||
|
'dashboard.addFirstWish': 'Добавить первое желание',
|
||||||
|
'archive.title': 'Архив',
|
||||||
|
'archive.description': 'Отложенные желания. Их видите только вы, и их можно вернуть в активный список.',
|
||||||
|
'archive.emptyTitle': 'Архив пуст',
|
||||||
|
'archive.emptyText': 'Архивированные желания появятся здесь.',
|
||||||
|
'completed.title': 'Исполненные',
|
||||||
|
'completed.description': 'Желания, которые уже исполнились. Из любого можно создать новое.',
|
||||||
|
'completed.emptyTitle': 'Исполненных желаний пока нет',
|
||||||
|
'completed.emptyText': 'Когда желание исполнится, отметьте его, и оно появится здесь.',
|
||||||
|
'trash.title': 'Корзина',
|
||||||
|
'trash.description': 'Удалённые желания хранятся {days}, затем удаляются навсегда.',
|
||||||
|
'trash.emptyTitle': 'Корзина пуста',
|
||||||
|
'trash.emptyText': 'Удалённые желания будут появляться здесь на {days}.',
|
||||||
|
'trash.autoRemove': 'Автоудаление через {days}',
|
||||||
|
'profile.title': 'Профиль',
|
||||||
|
'profile.publicPage': 'Публичная страница доступна по адресу ',
|
||||||
|
'profile.slug': 'Slug (публичный URL)',
|
||||||
|
'profile.displayName': 'Отображаемое имя',
|
||||||
|
'profile.bio': 'О себе',
|
||||||
|
'profile.avatarUrl': 'Ссылка на аватар',
|
||||||
|
'profile.avatar': 'Аватар',
|
||||||
|
'profile.avatarHint': 'Можно вставить ссылку или загрузить фото до 2 MB.',
|
||||||
|
'profile.uploadAvatar': 'Загрузить фото',
|
||||||
|
'profile.avatarTooLarge': 'Фото должно быть не больше 2 MB',
|
||||||
|
'profile.avatarUnsupported': 'Поддерживаются JPEG, PNG, WebP и GIF',
|
||||||
|
'profile.saved': 'Профиль сохранён',
|
||||||
|
'profile.avatarUploaded': 'Аватар обновлён',
|
||||||
|
'profile.saveFailed': 'Не удалось сохранить',
|
||||||
|
'public.loadingWishes': 'Загрузка желаний...',
|
||||||
|
'public.notFoundTitle': 'Профиль не найден',
|
||||||
|
'public.notFoundText': 'Проверьте ссылку и попробуйте ещё раз. Slug чувствителен к регистру.',
|
||||||
|
'public.wishlistTitle': 'Список желаний {name}',
|
||||||
|
'public.emptyTitle': 'Желаний пока нет',
|
||||||
|
'public.emptyText': 'Загляните позже!',
|
||||||
|
'notFound.text': 'Не удалось найти эту страницу.',
|
||||||
|
'protected.loading': 'Загрузка...',
|
||||||
|
'wish.action.edit': 'Редактировать',
|
||||||
|
'wish.action.complete': 'Отметить исполненным',
|
||||||
|
'wish.action.archive': 'В архив',
|
||||||
|
'wish.action.delete': 'Удалить',
|
||||||
|
'wish.action.restore': 'Восстановить',
|
||||||
|
'wish.action.duplicate': 'Создать копию как новое',
|
||||||
|
'wish.actions': 'Действия',
|
||||||
|
'wish.openLink': 'открыть ссылку',
|
||||||
|
'wish.badge.new': 'новое',
|
||||||
|
'wish.badge.fulfilled': 'исполнено',
|
||||||
|
'wish.badge.archived': 'архив',
|
||||||
|
'wish.badge.trash': 'корзина',
|
||||||
|
'wishForm.addTitle': 'Добавить желание',
|
||||||
|
'wishForm.editTitle': 'Редактировать желание',
|
||||||
|
'wishForm.addDescription': 'Расскажите, чего хочется. По ссылке мы попробуем подтянуть картинку.',
|
||||||
|
'wishForm.editDescription': 'Обновите детали желания.',
|
||||||
|
'wishForm.title': 'Название',
|
||||||
|
'wishForm.titlePlaceholder': 'Гейзерная кофеварка, размер 3',
|
||||||
|
'wishForm.price': 'Цена (необязательно)',
|
||||||
|
'wishForm.pricePlaceholder': 'например, 2490',
|
||||||
|
'wishForm.currency': 'Валюта',
|
||||||
|
'wishForm.link': 'Ссылка (необязательно)',
|
||||||
|
'wishForm.linkHint': 'После сохранения попробуем подтянуть превью-картинку по ссылке.',
|
||||||
|
'wishForm.comment': 'Комментарий (необязательно)',
|
||||||
|
'wishForm.commentPlaceholder': 'Размер / цвет / заметки...',
|
||||||
|
'wishForm.image': 'Картинка',
|
||||||
|
'wishForm.uploadCustom': 'Загрузить свою',
|
||||||
|
'wishForm.refreshFromLink': 'Обновить по ссылке',
|
||||||
|
'wishForm.resetImage': 'Сбросить на стандартную',
|
||||||
|
'wishForm.addSubmit': 'Добавить желание',
|
||||||
|
'toast.genericError': 'Что-то пошло не так',
|
||||||
|
'toast.wishAdded': 'Желание добавлено',
|
||||||
|
'toast.saved': 'Сохранено',
|
||||||
|
'toast.archived': 'Перемещено в архив',
|
||||||
|
'toast.fulfilled': 'Отмечено исполненным',
|
||||||
|
'toast.deleted': 'Перемещено в корзину (30 дней на восстановление)',
|
||||||
|
'toast.restored': 'Восстановлено',
|
||||||
|
'toast.duplicated': 'Новое желание создано из исполненного',
|
||||||
|
'toast.imageUpdated': 'Картинка обновлена',
|
||||||
|
'toast.imageRefreshed': 'Картинка обновлена по ссылке',
|
||||||
|
'toast.imageFetchFailed': 'Не удалось получить картинку по ссылке',
|
||||||
|
'toast.imageReset': 'Картинка сброшена на стандартную',
|
||||||
|
'validation.invalid': 'Проверьте значение поля',
|
||||||
|
'validation.slug': 'Slug: 3-32 символа, строчные латинские буквы, цифры и дефисы',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
'app.name': 'Family Wishlist',
|
||||||
|
'language.ru': 'Русский',
|
||||||
|
'language.en': 'English',
|
||||||
|
'language.switch': 'Language',
|
||||||
|
'common.loading': 'Loading...',
|
||||||
|
'common.cancel': 'Cancel',
|
||||||
|
'common.save': 'Save',
|
||||||
|
'common.saveChanges': 'Save changes',
|
||||||
|
'common.backHome': 'Back to home',
|
||||||
|
'common.days.one': '{count} day',
|
||||||
|
'common.days.few': '{count} days',
|
||||||
|
'common.days.many': '{count} days',
|
||||||
|
'footer.frontend': 'frontend v{version}',
|
||||||
|
'footer.backend': 'backend v{version}',
|
||||||
|
'header.active': 'Active',
|
||||||
|
'header.fulfilled': 'Fulfilled',
|
||||||
|
'header.archive': 'Archive',
|
||||||
|
'header.trash': 'Trash',
|
||||||
|
'header.signedInAs': 'signed in as {name}',
|
||||||
|
'header.profileSettings': 'Profile settings',
|
||||||
|
'header.profile': 'Profile',
|
||||||
|
'header.logout': 'Log out',
|
||||||
|
'login.title': 'Welcome back',
|
||||||
|
'login.description': 'Sign in to manage your wishlist. Credentials are set up via the server environment.',
|
||||||
|
'login.username': 'Username',
|
||||||
|
'login.password': 'Password',
|
||||||
|
'login.submit': 'Sign in',
|
||||||
|
'login.failed': 'Login failed',
|
||||||
|
'dashboard.title': 'Your wishlist',
|
||||||
|
'dashboard.description': 'Add things you dream of. Share your public page at ',
|
||||||
|
'dashboard.addWish': 'Add wish',
|
||||||
|
'dashboard.emptyTitle': 'No wishes yet',
|
||||||
|
'dashboard.emptyText': "Start by adding something you'd love to receive.",
|
||||||
|
'dashboard.addFirstWish': 'Add your first wish',
|
||||||
|
'archive.title': 'Archive',
|
||||||
|
'archive.description': 'Wishes you put aside. Only you see this. Restore them to your active list any time.',
|
||||||
|
'archive.emptyTitle': 'Archive is empty',
|
||||||
|
'archive.emptyText': 'Archived wishes will show up here.',
|
||||||
|
'completed.title': 'Fulfilled',
|
||||||
|
'completed.description': "Wishes you've received. You can create a new wish based on any of them.",
|
||||||
|
'completed.emptyTitle': 'Nothing fulfilled yet',
|
||||||
|
'completed.emptyText': 'When a wish comes true, mark it as fulfilled and it lands here.',
|
||||||
|
'trash.title': 'Trash',
|
||||||
|
'trash.description': 'Deleted wishes are kept for {days}, then permanently removed.',
|
||||||
|
'trash.emptyTitle': 'Trash is empty',
|
||||||
|
'trash.emptyText': 'Deleted wishes will appear here for {days}.',
|
||||||
|
'trash.autoRemove': 'Auto-removes in {days}',
|
||||||
|
'profile.title': 'Profile',
|
||||||
|
'profile.publicPage': 'Your public page lives at ',
|
||||||
|
'profile.slug': 'Slug (public URL)',
|
||||||
|
'profile.displayName': 'Display name',
|
||||||
|
'profile.bio': 'Bio',
|
||||||
|
'profile.avatarUrl': 'Avatar URL',
|
||||||
|
'profile.avatar': 'Avatar',
|
||||||
|
'profile.avatarHint': 'Paste a link or upload a photo up to 2 MB.',
|
||||||
|
'profile.uploadAvatar': 'Upload photo',
|
||||||
|
'profile.avatarTooLarge': 'Avatar must be 2 MB or less',
|
||||||
|
'profile.avatarUnsupported': 'JPEG, PNG, WebP and GIF are supported',
|
||||||
|
'profile.saved': 'Profile saved',
|
||||||
|
'profile.avatarUploaded': 'Avatar updated',
|
||||||
|
'profile.saveFailed': 'Save failed',
|
||||||
|
'public.loadingWishes': 'Loading wishes...',
|
||||||
|
'public.notFoundTitle': 'Profile not found',
|
||||||
|
'public.notFoundText': 'Check the link and try again. Slugs are case-sensitive.',
|
||||||
|
'public.wishlistTitle': "{name}'s wishlist",
|
||||||
|
'public.emptyTitle': 'No wishes yet',
|
||||||
|
'public.emptyText': 'Check back later!',
|
||||||
|
'notFound.text': "We couldn't find that page.",
|
||||||
|
'protected.loading': 'Loading...',
|
||||||
|
'wish.action.edit': 'Edit',
|
||||||
|
'wish.action.complete': 'Mark fulfilled',
|
||||||
|
'wish.action.archive': 'Archive',
|
||||||
|
'wish.action.delete': 'Delete',
|
||||||
|
'wish.action.restore': 'Restore',
|
||||||
|
'wish.action.duplicate': 'Create copy as new',
|
||||||
|
'wish.actions': 'Actions',
|
||||||
|
'wish.openLink': 'open link',
|
||||||
|
'wish.badge.new': 'new',
|
||||||
|
'wish.badge.fulfilled': 'fulfilled',
|
||||||
|
'wish.badge.archived': 'archived',
|
||||||
|
'wish.badge.trash': 'trash',
|
||||||
|
'wishForm.addTitle': 'Add a wish',
|
||||||
|
'wishForm.editTitle': 'Edit wish',
|
||||||
|
'wishForm.addDescription': 'Tell us what you want. A link helps us grab a preview image automatically.',
|
||||||
|
'wishForm.editDescription': 'Update the details of your wish.',
|
||||||
|
'wishForm.title': 'Title',
|
||||||
|
'wishForm.titlePlaceholder': 'Moka pot, size 3',
|
||||||
|
'wishForm.price': 'Price (optional)',
|
||||||
|
'wishForm.pricePlaceholder': 'e.g. 2490',
|
||||||
|
'wishForm.currency': 'Currency',
|
||||||
|
'wishForm.link': 'Link (optional)',
|
||||||
|
'wishForm.linkHint': 'We will try to pull a preview image from the link after saving.',
|
||||||
|
'wishForm.comment': 'Comment (optional)',
|
||||||
|
'wishForm.commentPlaceholder': 'Size / color / notes...',
|
||||||
|
'wishForm.image': 'Image',
|
||||||
|
'wishForm.uploadCustom': 'Upload custom',
|
||||||
|
'wishForm.refreshFromLink': 'Refresh from link',
|
||||||
|
'wishForm.resetImage': 'Reset to default',
|
||||||
|
'wishForm.addSubmit': 'Add wish',
|
||||||
|
'toast.genericError': 'Something went wrong',
|
||||||
|
'toast.wishAdded': 'Wish added',
|
||||||
|
'toast.saved': 'Saved',
|
||||||
|
'toast.archived': 'Moved to archive',
|
||||||
|
'toast.fulfilled': 'Marked as fulfilled',
|
||||||
|
'toast.deleted': 'Moved to trash (30 days to restore)',
|
||||||
|
'toast.restored': 'Restored',
|
||||||
|
'toast.duplicated': 'New wish created from the fulfilled one',
|
||||||
|
'toast.imageUpdated': 'Image updated',
|
||||||
|
'toast.imageRefreshed': 'Image refreshed from link',
|
||||||
|
'toast.imageFetchFailed': 'Could not fetch image from link',
|
||||||
|
'toast.imageReset': 'Reset to default image',
|
||||||
|
'validation.invalid': 'Check this field',
|
||||||
|
'validation.slug': 'Slug must be 3-32 chars, lowercase letters, digits, hyphens',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TranslationKey = keyof typeof translations.ru;
|
||||||
|
|
||||||
|
interface I18nContextValue {
|
||||||
|
language: Language;
|
||||||
|
locale: string;
|
||||||
|
setLanguage: (language: Language) => void;
|
||||||
|
t: (key: TranslationKey, vars?: Record<string, string | number>) => string;
|
||||||
|
dayCount: (count: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||||
|
|
||||||
|
function isLanguage(value: string | null): value is Language {
|
||||||
|
return value === 'ru' || value === 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLanguage(): Language {
|
||||||
|
if (typeof window === 'undefined') return FALLBACK_LANGUAGE;
|
||||||
|
const saved = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (isLanguage(saved)) return saved;
|
||||||
|
const languages = navigator.languages.length ? navigator.languages : [navigator.language];
|
||||||
|
for (const lang of languages) {
|
||||||
|
const normalized = lang.toLowerCase();
|
||||||
|
if (normalized.startsWith('ru')) return 'ru';
|
||||||
|
if (normalized.startsWith('en')) return 'en';
|
||||||
|
}
|
||||||
|
return FALLBACK_LANGUAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(template: string, vars?: Record<string, string | number>): string {
|
||||||
|
if (!vars) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, key: string) => String(vars[key] ?? `{${key}}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayKey(language: Language, count: number): TranslationKey {
|
||||||
|
if (language === 'en') return count === 1 ? 'common.days.one' : 'common.days.many';
|
||||||
|
const mod10 = count % 10;
|
||||||
|
const mod100 = count % 100;
|
||||||
|
if (mod10 === 1 && mod100 !== 11) return 'common.days.one';
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'common.days.few';
|
||||||
|
return 'common.days.many';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [language, setLanguageState] = useState<Language>(() => detectLanguage());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = language;
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, language);
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const setLanguage = useCallback((next: Language) => setLanguageState(next), []);
|
||||||
|
const t = useCallback(
|
||||||
|
(key: TranslationKey, vars?: Record<string, string | number>) =>
|
||||||
|
interpolate(translations[language][key] ?? translations[FALLBACK_LANGUAGE][key], vars),
|
||||||
|
[language],
|
||||||
|
);
|
||||||
|
const dayCount = useCallback(
|
||||||
|
(count: number) => t(dayKey(language, count), { count }),
|
||||||
|
[language, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<I18nContextValue>(
|
||||||
|
() => ({
|
||||||
|
language,
|
||||||
|
locale: language === 'ru' ? 'ru-RU' : 'en-US',
|
||||||
|
setLanguage,
|
||||||
|
t,
|
||||||
|
dayCount,
|
||||||
|
}),
|
||||||
|
[dayCount, language, setLanguage, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n(): I18nContextValue {
|
||||||
|
const value = useContext(I18nContext);
|
||||||
|
if (!value) throw new Error('useI18n must be used within I18nProvider');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateValidation(t: I18nContextValue['t'], message: string | undefined): string {
|
||||||
|
if (!message) return t('validation.invalid');
|
||||||
|
if (message.includes('Slug must be')) return t('validation.slug');
|
||||||
|
return t('validation.invalid');
|
||||||
|
}
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
export function formatPrice(price: string | null | undefined, currency: string): string | null {
|
export function formatPrice(
|
||||||
|
price: string | null | undefined,
|
||||||
|
currency: string,
|
||||||
|
locale?: string,
|
||||||
|
): string | null {
|
||||||
if (!price) return null;
|
if (!price) return null;
|
||||||
const n = Number(price);
|
const n = Number(price);
|
||||||
if (Number.isNaN(n)) return `${price} ${currency}`;
|
if (Number.isNaN(n)) return `${price} ${currency}`;
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat(undefined, {
|
return new Intl.NumberFormat(locale, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency,
|
currency,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
}).format(n);
|
}).format(n);
|
||||||
} catch {
|
} catch {
|
||||||
return `${n.toLocaleString()} ${currency}`;
|
return `${n.toLocaleString(locale)} ${currency}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(iso: string | Date): string {
|
export function formatDate(iso: string | Date, locale?: string): string {
|
||||||
const d = typeof iso === 'string' ? new Date(iso) : iso;
|
const d = typeof iso === 'string' ? new Date(iso) : iso;
|
||||||
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
return d.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function daysLeftUntil(iso: string | Date, retentionDays: number): number {
|
export function daysLeftUntil(iso: string | Date, retentionDays: number): number {
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
useWishes,
|
useWishes,
|
||||||
} from '@/features/wishes/wishes.hooks';
|
} from '@/features/wishes/wishes.hooks';
|
||||||
import { Archive } from 'lucide-react';
|
import { Archive } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
export function ArchivePage() {
|
export function ArchivePage() {
|
||||||
|
const { t } = useI18n();
|
||||||
const { data, isLoading } = useWishes('archived');
|
const { data, isLoading } = useWishes('archived');
|
||||||
const restore = useRestoreWish();
|
const restore = useRestoreWish();
|
||||||
const remove = useDeleteWish();
|
const remove = useDeleteWish();
|
||||||
@@ -14,19 +16,19 @@ export function ArchivePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<section>
|
<section>
|
||||||
<h1 className="font-display text-3xl">Archive</h1>
|
<h1 className="font-display text-3xl">{t('archive.title')}</h1>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Wishes you put aside. Only you see this. Restore them to your active list any time.
|
{t('archive.description')}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isLoading && <div className="text-muted">Loading...</div>}
|
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
|
||||||
|
|
||||||
{!isLoading && data && data.length === 0 && (
|
{!isLoading && data && data.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||||
<Archive className="h-10 w-10 text-muted" />
|
<Archive className="h-10 w-10 text-muted" />
|
||||||
<h2 className="text-xl font-semibold">Archive is empty</h2>
|
<h2 className="text-xl font-semibold">{t('archive.emptyTitle')}</h2>
|
||||||
<p className="text-sm text-muted">Archived wishes will show up here.</p>
|
<p className="text-sm text-muted">{t('archive.emptyText')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
useDuplicateWish,
|
useDuplicateWish,
|
||||||
useWishes,
|
useWishes,
|
||||||
} from '@/features/wishes/wishes.hooks';
|
} from '@/features/wishes/wishes.hooks';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
export function CompletedPage() {
|
export function CompletedPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
const { data, isLoading } = useWishes('completed');
|
const { data, isLoading } = useWishes('completed');
|
||||||
const duplicate = useDuplicateWish();
|
const duplicate = useDuplicateWish();
|
||||||
const remove = useDeleteWish();
|
const remove = useDeleteWish();
|
||||||
@@ -14,20 +16,20 @@ export function CompletedPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<section>
|
<section>
|
||||||
<h1 className="font-display text-3xl">Fulfilled</h1>
|
<h1 className="font-display text-3xl">{t('completed.title')}</h1>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Wishes you've received. You can create a new wish based on any of them.
|
{t('completed.description')}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isLoading && <div className="text-muted">Loading...</div>}
|
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
|
||||||
|
|
||||||
{!isLoading && data && data.length === 0 && (
|
{!isLoading && data && data.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||||
<CheckCircle2 className="h-10 w-10 text-muted" />
|
<CheckCircle2 className="h-10 w-10 text-muted" />
|
||||||
<h2 className="text-xl font-semibold">Nothing fulfilled yet</h2>
|
<h2 className="text-xl font-semibold">{t('completed.emptyTitle')}</h2>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
When a wish comes true, mark it as fulfilled and it lands here.
|
{t('completed.emptyText')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
} from '@/features/wishes/wishes.hooks';
|
} from '@/features/wishes/wishes.hooks';
|
||||||
import { useAuthStore } from '@/features/auth/authStore';
|
import { useAuthStore } from '@/features/auth/authStore';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
const { data, isLoading } = useWishes('active');
|
const { data, isLoading } = useWishes('active');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [editing, setEditing] = useState<Wish | null>(null);
|
const [editing, setEditing] = useState<Wish | null>(null);
|
||||||
@@ -26,9 +28,9 @@ export function DashboardPage() {
|
|||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<section className="flex flex-wrap items-end justify-between gap-4">
|
<section className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-3xl">Your wishlist</h1>
|
<h1 className="font-display text-3xl">{t('dashboard.title')}</h1>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Add things you dream of. Share your public page at{' '}
|
{t('dashboard.description')}
|
||||||
{user && (
|
{user && (
|
||||||
<Link
|
<Link
|
||||||
to={`/u/${user.slug}`}
|
to={`/u/${user.slug}`}
|
||||||
@@ -42,7 +44,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Button size="lg" onClick={() => setCreating(true)}>
|
<Button size="lg" onClick={() => setCreating(true)}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add wish
|
{t('dashboard.addWish')}
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -62,14 +64,14 @@ export function DashboardPage() {
|
|||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||||
<img src="/empty-state.svg" alt="" className="h-40 w-40 opacity-90" />
|
<img src="/empty-state.svg" alt="" className="h-40 w-40 opacity-90" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">No wishes yet</h2>
|
<h2 className="text-xl font-semibold">{t('dashboard.emptyTitle')}</h2>
|
||||||
<p className="mt-1 text-sm text-muted">
|
<p className="mt-1 text-sm text-muted">
|
||||||
Start by adding something you'd love to receive.
|
{t('dashboard.emptyText')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setCreating(true)}>
|
<Button onClick={() => setCreating(true)}>
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
Add your first wish
|
{t('dashboard.addFirstWish')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import { Label } from '@/components/ui/Label';
|
|||||||
import { useAuthStore } from '@/features/auth/authStore';
|
import { useAuthStore } from '@/features/auth/authStore';
|
||||||
import { ApiError } from '@/lib/api';
|
import { ApiError } from '@/lib/api';
|
||||||
import { Footer } from '@/components/Layout/Footer';
|
import { Footer } from '@/components/Layout/Footer';
|
||||||
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||||
|
import { translateValidation, useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
const { user, login } = useAuthStore();
|
const { user, login } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -39,29 +42,32 @@ export function LoginPage() {
|
|||||||
navigate(from, { replace: true });
|
navigate(from, { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) toast.error(err.message);
|
if (err instanceof ApiError) toast.error(err.message);
|
||||||
else toast.error('Login failed');
|
else toast.error(t('login.failed'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<div className="container-page flex justify-end pt-6">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
<div className="container-page flex flex-1 items-center justify-center py-12">
|
<div className="container-page flex flex-1 items-center justify-center py-12">
|
||||||
<div className="w-full max-w-md animate-fade-in-up">
|
<div className="w-full max-w-md animate-fade-in-up">
|
||||||
<div className="mb-6 flex items-center justify-center gap-2">
|
<div className="mb-6 flex items-center justify-center gap-2">
|
||||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
|
<span className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
|
||||||
<Gift className="h-5 w-5" />
|
<Gift className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
<h1 className="font-display text-3xl">Family Wishlist</h1>
|
<h1 className="font-display text-3xl">{t('app.name')}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-surface p-6 shadow-card sm:p-8">
|
<div className="rounded-xl border border-border bg-surface p-6 shadow-card sm:p-8">
|
||||||
<h2 className="mb-1 text-xl font-semibold">Welcome back</h2>
|
<h2 className="mb-1 text-xl font-semibold">{t('login.title')}</h2>
|
||||||
<p className="mb-6 text-sm text-muted">
|
<p className="mb-6 text-sm text-muted">
|
||||||
Sign in to manage your wishlist. Credentials are set up via the server environment.
|
{t('login.description')}
|
||||||
</p>
|
</p>
|
||||||
<form className="grid gap-4" onSubmit={submit}>
|
<form className="grid gap-4" onSubmit={submit}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<Label htmlFor="username">Username</Label>
|
<Label htmlFor="username">{t('login.username')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -69,11 +75,13 @@ export function LoginPage() {
|
|||||||
{...register('username')}
|
{...register('username')}
|
||||||
/>
|
/>
|
||||||
{errors.username && (
|
{errors.username && (
|
||||||
<span className="field__error">{errors.username.message}</span>
|
<span className="field__error">
|
||||||
|
{translateValidation(t, errors.username.message)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">{t('login.password')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -81,12 +89,14 @@ export function LoginPage() {
|
|||||||
{...register('password')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<span className="field__error">{errors.password.message}</span>
|
<span className="field__error">
|
||||||
|
{translateValidation(t, errors.password.message)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
<Button type="submit" size="lg" disabled={isSubmitting}>
|
||||||
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
Sign in
|
{t('login.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
export function NotFoundPage() {
|
export function NotFoundPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center p-6">
|
<div className="flex min-h-screen items-center justify-center p-6">
|
||||||
<div className="max-w-md rounded-xl border border-border bg-surface p-8 text-center shadow-card">
|
<div className="max-w-md rounded-xl border border-border bg-surface p-8 text-center shadow-card">
|
||||||
<h1 className="font-display text-4xl">404</h1>
|
<h1 className="font-display text-4xl">404</h1>
|
||||||
<p className="mt-2 text-muted">We couldn't find that page.</p>
|
<p className="mt-2 text-muted">{t('notFound.text')}</p>
|
||||||
<Link to="/" className="mt-4 inline-block">
|
<Link to="/" className="mt-4 inline-block">
|
||||||
<Button variant="secondary">Back to home</Button>
|
<Button variant="secondary">{t('common.backHome')}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -8,17 +8,23 @@ import {
|
|||||||
type Profile,
|
type Profile,
|
||||||
} from '@family-wishlist/shared';
|
} from '@family-wishlist/shared';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Gift, Loader2, Upload } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Label } from '@/components/ui/Label';
|
import { Label } from '@/components/ui/Label';
|
||||||
import { Textarea } from '@/components/ui/Textarea';
|
import { Textarea } from '@/components/ui/Textarea';
|
||||||
import { api, ApiError } from '@/lib/api';
|
import { api, ApiError } from '@/lib/api';
|
||||||
import { useAuthStore } from '@/features/auth/authStore';
|
import { useAuthStore } from '@/features/auth/authStore';
|
||||||
|
import { translateValidation, useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
||||||
|
const AVATAR_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||||
|
|
||||||
export function ProfileSettingsPage() {
|
export function ProfileSettingsPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
const refresh = useAuthStore((s) => s.refresh);
|
const refresh = useAuthStore((s) => s.refresh);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['profile'],
|
queryKey: ['profile'],
|
||||||
@@ -29,6 +35,7 @@ export function ProfileSettingsPage() {
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
watch,
|
||||||
formState: { errors, isSubmitting, isDirty },
|
formState: { errors, isSubmitting, isDirty },
|
||||||
} = useForm<UpdateProfileInput>({
|
} = useForm<UpdateProfileInput>({
|
||||||
resolver: zodResolver(updateProfileSchema),
|
resolver: zodResolver(updateProfileSchema),
|
||||||
@@ -50,7 +57,7 @@ export function ProfileSettingsPage() {
|
|||||||
mutationFn: (values: UpdateProfileInput) =>
|
mutationFn: (values: UpdateProfileInput) =>
|
||||||
api.patch<Profile, UpdateProfileInput>('/api/profile', values),
|
api.patch<Profile, UpdateProfileInput>('/api/profile', values),
|
||||||
onSuccess: (p) => {
|
onSuccess: (p) => {
|
||||||
toast.success('Profile saved');
|
toast.success(t('profile.saved'));
|
||||||
void queryClient.invalidateQueries({ queryKey: ['profile'] });
|
void queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||||
void refresh();
|
void refresh();
|
||||||
reset({
|
reset({
|
||||||
@@ -62,10 +69,46 @@ export function ProfileSettingsPage() {
|
|||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
if (err instanceof ApiError) toast.error(err.message);
|
if (err instanceof ApiError) toast.error(err.message);
|
||||||
else toast.error('Save failed');
|
else toast.error(t('profile.saveFailed'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const uploadAvatar = useMutation({
|
||||||
|
mutationFn: (file: File) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return api.upload<Profile>('/api/profile/avatar', form);
|
||||||
|
},
|
||||||
|
onSuccess: (p) => {
|
||||||
|
toast.success(t('profile.avatarUploaded'));
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||||
|
void refresh();
|
||||||
|
reset({
|
||||||
|
slug: p.slug,
|
||||||
|
displayName: p.displayName,
|
||||||
|
bio: p.bio ?? '',
|
||||||
|
avatarUrl: p.avatarUrl ?? '',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (err instanceof ApiError) toast.error(err.message);
|
||||||
|
else toast.error(t('profile.saveFailed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleAvatarFile(file: File | undefined): void {
|
||||||
|
if (!file) return;
|
||||||
|
if (!AVATAR_MIME_TYPES.has(file.type)) {
|
||||||
|
toast.error(t('profile.avatarUnsupported'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_AVATAR_BYTES) {
|
||||||
|
toast.error(t('profile.avatarTooLarge'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadAvatar.mutate(file);
|
||||||
|
}
|
||||||
|
|
||||||
const submit = handleSubmit((values) => {
|
const submit = handleSubmit((values) => {
|
||||||
const payload: UpdateProfileInput = {
|
const payload: UpdateProfileInput = {
|
||||||
...values,
|
...values,
|
||||||
@@ -75,43 +118,91 @@ export function ProfileSettingsPage() {
|
|||||||
update.mutate(payload);
|
update.mutate(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const avatarPreview = watch('avatarUrl') || data?.avatarUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid max-w-2xl gap-6">
|
<div className="grid max-w-2xl gap-6">
|
||||||
<section>
|
<section>
|
||||||
<h1 className="font-display text-3xl">Profile</h1>
|
<h1 className="font-display text-3xl">{t('profile.title')}</h1>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Your public page lives at <code className="rounded bg-ink/5 px-1.5 py-0.5">/u/{data?.slug ?? '...'}</code>.
|
{t('profile.publicPage')}
|
||||||
|
<code className="rounded bg-ink/5 px-1.5 py-0.5">/u/{data?.slug ?? '...'}</code>.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-muted">Loading...</div>
|
<div className="text-muted">{t('common.loading')}</div>
|
||||||
) : (
|
) : (
|
||||||
<form className="grid gap-4 rounded-xl border border-border bg-surface p-6 shadow-card" onSubmit={submit}>
|
<form className="grid gap-4 rounded-xl border border-border bg-surface p-6 shadow-card" onSubmit={submit}>
|
||||||
<div className="field">
|
<section className="flex flex-wrap items-center gap-4 rounded-md border border-border bg-surface-muted p-4">
|
||||||
<Label htmlFor="slug">Slug (public URL)</Label>
|
<span className="inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-primary text-primary-foreground">
|
||||||
<Input id="slug" {...register('slug')} />
|
{avatarPreview ? (
|
||||||
{errors.slug && <span className="field__error">{errors.slug.message}</span>}
|
<img src={avatarPreview} alt="" className="h-16 w-16 object-cover" />
|
||||||
|
) : (
|
||||||
|
<Gift className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="text-sm font-semibold">{t('profile.avatar')}</h2>
|
||||||
|
<p className="text-xs text-muted">{t('profile.avatarHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
handleAvatarFile(e.target.files?.[0]);
|
||||||
|
e.currentTarget.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploadAvatar.isPending}
|
||||||
|
>
|
||||||
|
{uploadAvatar.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t('profile.uploadAvatar')}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<Label htmlFor="displayName">Display name</Label>
|
<Label htmlFor="slug">{t('profile.slug')}</Label>
|
||||||
<Input id="displayName" {...register('displayName')} />
|
<Input id="slug" {...register('slug')} />
|
||||||
{errors.displayName && (
|
{errors.slug && (
|
||||||
<span className="field__error">{errors.displayName.message}</span>
|
<span className="field__error">{translateValidation(t, errors.slug.message)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<Label htmlFor="bio">Bio</Label>
|
<Label htmlFor="displayName">{t('profile.displayName')}</Label>
|
||||||
|
<Input id="displayName" {...register('displayName')} />
|
||||||
|
{errors.displayName && (
|
||||||
|
<span className="field__error">
|
||||||
|
{translateValidation(t, errors.displayName.message)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<Label htmlFor="bio">{t('profile.bio')}</Label>
|
||||||
<Textarea id="bio" rows={3} {...register('bio')} />
|
<Textarea id="bio" rows={3} {...register('bio')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<Label htmlFor="avatarUrl">Avatar URL</Label>
|
<Label htmlFor="avatarUrl">{t('profile.avatarUrl')}</Label>
|
||||||
<Input id="avatarUrl" type="url" {...register('avatarUrl')} />
|
<Input id="avatarUrl" inputMode="url" {...register('avatarUrl')} />
|
||||||
|
{errors.avatarUrl && (
|
||||||
|
<span className="field__error">
|
||||||
|
{translateValidation(t, errors.avatarUrl.message)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button type="submit" disabled={!isDirty || isSubmitting}>
|
<Button type="submit" disabled={!isDirty || isSubmitting}>
|
||||||
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
Save changes
|
{t('common.saveChanges')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import { api } from '@/lib/api';
|
|||||||
import { WishCard } from '@/components/WishCard/WishCard';
|
import { WishCard } from '@/components/WishCard/WishCard';
|
||||||
import { Footer } from '@/components/Layout/Footer';
|
import { Footer } from '@/components/Layout/Footer';
|
||||||
import { Gift } from 'lucide-react';
|
import { Gift } from 'lucide-react';
|
||||||
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
export function PublicProfilePage() {
|
export function PublicProfilePage() {
|
||||||
|
const { t } = useI18n();
|
||||||
const { slug = '' } = useParams<{ slug: string }>();
|
const { slug = '' } = useParams<{ slug: string }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -53,14 +56,17 @@ export function PublicProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<div className="container-page flex justify-end pt-6">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
<main className="container-page flex-1 py-10">
|
<main className="container-page flex-1 py-10">
|
||||||
{profile.isLoading && <div className="text-muted">Loading...</div>}
|
{profile.isLoading && <div className="text-muted">{t('common.loading')}</div>}
|
||||||
|
|
||||||
{profile.isError && (
|
{profile.isError && (
|
||||||
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface p-8 text-center shadow-card">
|
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface p-8 text-center shadow-card">
|
||||||
<h1 className="font-display text-2xl">Profile not found</h1>
|
<h1 className="font-display text-2xl">{t('public.notFoundTitle')}</h1>
|
||||||
<p className="mt-2 text-sm text-muted">
|
<p className="mt-2 text-sm text-muted">
|
||||||
Check the link and try again. Slugs are case-sensitive.
|
{t('public.notFoundText')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -79,18 +85,20 @@ export function PublicProfilePage() {
|
|||||||
<Gift className="h-6 w-6" />
|
<Gift className="h-6 w-6" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<h1 className="font-display text-4xl">{profile.data.displayName}'s wishlist</h1>
|
<h1 className="font-display text-4xl">
|
||||||
|
{t('public.wishlistTitle', { name: profile.data.displayName })}
|
||||||
|
</h1>
|
||||||
{profile.data.bio && (
|
{profile.data.bio && (
|
||||||
<p className="max-w-xl text-muted">{profile.data.bio}</p>
|
<p className="max-w-xl text-muted">{profile.data.bio}</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{wishes.isLoading && <div className="text-muted">Loading wishes...</div>}
|
{wishes.isLoading && <div className="text-muted">{t('public.loadingWishes')}</div>}
|
||||||
|
|
||||||
{wishes.data && wishes.data.length === 0 && (
|
{wishes.data && wishes.data.length === 0 && (
|
||||||
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||||
<h2 className="text-xl font-semibold">No wishes yet</h2>
|
<h2 className="text-xl font-semibold">{t('public.emptyTitle')}</h2>
|
||||||
<p className="mt-1 text-sm text-muted">Check back later!</p>
|
<p className="mt-1 text-sm text-muted">{t('public.emptyText')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,27 +3,31 @@ import { TRASH_RETENTION_DAYS } from '@family-wishlist/shared';
|
|||||||
import { WishCard } from '@/components/WishCard/WishCard';
|
import { WishCard } from '@/components/WishCard/WishCard';
|
||||||
import { useRestoreWish, useWishes } from '@/features/wishes/wishes.hooks';
|
import { useRestoreWish, useWishes } from '@/features/wishes/wishes.hooks';
|
||||||
import { daysLeftUntil } from '@/lib/format';
|
import { daysLeftUntil } from '@/lib/format';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
export function TrashPage() {
|
export function TrashPage() {
|
||||||
|
const { dayCount, t } = useI18n();
|
||||||
const { data, isLoading } = useWishes('deleted');
|
const { data, isLoading } = useWishes('deleted');
|
||||||
const restore = useRestoreWish();
|
const restore = useRestoreWish();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<section>
|
<section>
|
||||||
<h1 className="font-display text-3xl">Trash</h1>
|
<h1 className="font-display text-3xl">{t('trash.title')}</h1>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Deleted wishes are kept for {TRASH_RETENTION_DAYS} days, then permanently removed.
|
{t('trash.description', { days: dayCount(TRASH_RETENTION_DAYS) })}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isLoading && <div className="text-muted">Loading...</div>}
|
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
|
||||||
|
|
||||||
{!isLoading && data && data.length === 0 && (
|
{!isLoading && data && data.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||||
<Trash2 className="h-10 w-10 text-muted" />
|
<Trash2 className="h-10 w-10 text-muted" />
|
||||||
<h2 className="text-xl font-semibold">Trash is empty</h2>
|
<h2 className="text-xl font-semibold">{t('trash.emptyTitle')}</h2>
|
||||||
<p className="text-sm text-muted">Deleted wishes will appear here for 30 days.</p>
|
<p className="text-sm text-muted">
|
||||||
|
{t('trash.emptyText', { days: dayCount(TRASH_RETENTION_DAYS) })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -41,7 +45,7 @@ export function TrashPage() {
|
|||||||
onRestore={() => restore.mutate(wish.id)}
|
onRestore={() => restore.mutate(wish.id)}
|
||||||
footer={
|
footer={
|
||||||
<p className="mt-2 text-xs font-medium text-warning">
|
<p className="mt-2 text-xs font-medium text-warning">
|
||||||
Auto-removes in {left} day{left === 1 ? '' : 's'}
|
{t('trash.autoRemove', { days: dayCount(left) })}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createBrowserRouter } from 'react-router-dom';
|
import { createBrowserRouter, type RouterProviderProps } from 'react-router-dom';
|
||||||
import { ProtectedRoute } from './components/Layout/ProtectedRoute';
|
import { ProtectedRoute } from './components/Layout/ProtectedRoute';
|
||||||
import { AppShell } from './components/Layout/AppShell';
|
import { AppShell } from './components/Layout/AppShell';
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
@@ -10,7 +10,7 @@ import { ProfileSettingsPage } from './pages/ProfileSettingsPage';
|
|||||||
import { PublicProfilePage } from './pages/PublicProfilePage';
|
import { PublicProfilePage } from './pages/PublicProfilePage';
|
||||||
import { NotFoundPage } from './pages/NotFoundPage';
|
import { NotFoundPage } from './pages/NotFoundPage';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router: RouterProviderProps['router'] = createBrowserRouter([
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: '/login', element: <LoginPage /> },
|
||||||
{ path: '/u/:slug', element: <PublicProfilePage /> },
|
{ path: '/u/:slug', element: <PublicProfilePage /> },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
family-wishlist-backend:
|
family-wishlist-backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "3055:80"
|
||||||
networks:
|
networks:
|
||||||
- postgres_default
|
- postgres_default
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ FROM deps AS build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY packages/shared packages/shared
|
COPY packages/shared packages/shared
|
||||||
COPY apps/backend apps/backend
|
COPY apps/backend apps/backend
|
||||||
|
RUN pnpm --filter @family-wishlist/shared build
|
||||||
RUN pnpm --filter @family-wishlist/backend build
|
RUN pnpm --filter @family-wishlist/backend build
|
||||||
|
|
||||||
# ---------- runtime ----------
|
# ---------- runtime ----------
|
||||||
@@ -48,4 +49,4 @@ WORKDIR /app/apps/backend
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
# Apply schema (idempotent; uses `db push` so no prior migrations required) +
|
# Apply schema (idempotent; uses `db push` so no prior migrations required) +
|
||||||
# seed env users + start server.
|
# seed env users + start server.
|
||||||
CMD ["sh", "-c", "pnpm exec prisma db push --accept-data-loss --skip-generate && pnpm seed && node dist/index.js"]
|
CMD ["sh", "-c", "pnpm exec prisma db push --accept-data-loss --skip-generate && node dist/prisma/seed.js && node dist/src/index.js"]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ FROM deps AS build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY packages/shared packages/shared
|
COPY packages/shared packages/shared
|
||||||
COPY apps/frontend apps/frontend
|
COPY apps/frontend apps/frontend
|
||||||
|
RUN pnpm --filter @family-wishlist/shared build
|
||||||
RUN pnpm --filter @family-wishlist/frontend build
|
RUN pnpm --filter @family-wishlist/frontend build
|
||||||
|
|
||||||
# ---------- runtime (nginx) ----------
|
# ---------- runtime (nginx) ----------
|
||||||
|
|||||||
@@ -3,16 +3,19 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./dist/index.js",
|
||||||
"types": "./src/index.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "echo 'shared is source-only'",
|
"build": "tsc -p tsconfig.json",
|
||||||
"lint": "echo 'skip'",
|
"lint": "echo 'skip'",
|
||||||
"dev": "echo 'shared is source-only'"
|
"dev": "tsc -p tsconfig.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|||||||
@@ -24,7 +24,20 @@ export const updateProfileSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
displayName: z.string().trim().min(1).max(64).optional(),
|
displayName: z.string().trim().min(1).max(64).optional(),
|
||||||
bio: z.string().trim().max(500).nullable().optional(),
|
bio: z.string().trim().max(500).nullable().optional(),
|
||||||
avatarUrl: z.string().url().nullable().optional(),
|
avatarUrl: z
|
||||||
|
.preprocess(
|
||||||
|
(value) => (value === '' ? null : value),
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.refine(
|
||||||
|
(value) => value.startsWith('/uploads/avatar/') || z.string().url().safeParse(value).success,
|
||||||
|
{
|
||||||
|
message: 'Avatar must be a URL or an uploaded avatar path',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.nullable(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"noEmit": true
|
"noEmit": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user