feat: initial commit — backend API + student cabinet frontend

- Go backend: auth (JWT), points earn/spend, QR token generation,
  partners, admin grant/stats endpoints with chi router
- Next.js 14 frontend: login, student dashboard, transaction history,
  QR display, partners list
- PostgreSQL migrations (4 tables), Redis cache, Docker Compose
- CORS middleware, role-based route protection, Zustand auth store

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
emil
2026-05-01 10:03:27 +03:00
co-authored by Claude Sonnet 4.6
commit 50b3c4198a
80 changed files with 10579 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
+36
View File
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+98
View File
@@ -0,0 +1,98 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Input, Card } from '@/components/ui';
import { api } from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import type { TokenPair, Profile, UserRole } from '@/lib/types';
function parseJwtRole(token: string): UserRole | null {
try {
const part = token.split('.')[1];
const padded = part.padEnd(part.length + ((4 - (part.length % 4)) % 4), '=');
const decoded = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/'))) as {
role?: string;
};
const role = decoded.role;
if (role === 'student' || role === 'partner' || role === 'admin') return role;
return null;
} catch {
return null;
}
}
const ROLE_REDIRECT: Record<UserRole, string> = {
student: '/dashboard',
partner: '/scan',
admin: '/admin/dashboard',
};
export default function LoginPage() {
const router = useRouter();
const { setTokens, setUser } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError('');
setLoading(true);
try {
const tokens = await api.post<TokenPair>('/api/v1/auth/login', { email, password });
setTokens(tokens.access_token, tokens.refresh_token);
const profile = await api.get<Profile>('/api/v1/me');
const role = parseJwtRole(tokens.access_token);
if (!role) throw new Error('Не удалось определить роль пользователя');
setUser({ ...profile, role });
router.push(ROLE_REDIRECT[role]);
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка входа');
} finally {
setLoading(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-sm">
<h1 className="mb-6 text-2xl font-bold text-white">CU Points</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
placeholder="student@cu.ru"
/>
<Input
label="Пароль"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
{error && (
<p className="rounded-lg bg-red-900/30 px-3 py-2 text-sm text-red-400">{error}</p>
)}
<Button type="submit" isLoading={loading} className="mt-2 w-full">
Войти
</Button>
</form>
</Card>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function ScanPage() {
// TODO: QR scanner + amount input form → POST /api/v1/partner/spend → show result
return (
<main className="p-6">
<p className="text-gray-500">Partner scan — coming soon</p>
</main>
);
}
+81
View File
@@ -0,0 +1,81 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { BalanceCard } from '@/components/BalanceCard';
import { TransactionList } from '@/components/TransactionList';
import { Spinner } from '@/components/ui';
import { api } from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import type { Profile, Transaction, PaginatedResponse } from '@/lib/types';
export default function StudentDashboardPage() {
const { user, updateBalance } = useAuthStore();
const [profile, setProfile] = useState<Profile | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loadingProfile, setLoadingProfile] = useState(true);
const [loadingTxs, setLoadingTxs] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const [p, page] = await Promise.all([
api.get<Profile>('/api/v1/me'),
api.get<PaginatedResponse<Transaction>>('/api/v1/me/transactions?limit=5'),
]);
setProfile(p);
updateBalance(p.balance);
setTransactions(page.transactions);
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка загрузки');
} finally {
setLoadingProfile(false);
setLoadingTxs(false);
}
}
load();
}, [updateBalance]);
if (loadingProfile) {
return (
<main className="flex min-h-screen items-center justify-center">
<Spinner size="lg" />
</main>
);
}
if (error) {
return (
<main className="flex min-h-screen items-center justify-center p-6">
<p className="text-red-400">{error}</p>
</main>
);
}
const displayName = profile?.name ?? user?.name ?? '';
const balance = profile?.balance ?? user?.balance ?? 0;
return (
<main className="mx-auto max-w-lg space-y-6 p-4 pb-10 pt-6">
<h1 className="text-xl font-bold text-white">
Привет, {displayName.split(' ')[0]} 👋
</h1>
<BalanceCard balance={balance} name={displayName} />
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-200">Последние операции</h2>
<Link href="/history" className="text-sm text-blue-400 hover:text-blue-300">
Вся история →
</Link>
</div>
<div className="rounded-2xl bg-gray-800 p-4 ring-1 ring-gray-700">
<TransactionList transactions={transactions} isLoading={loadingTxs} />
</div>
</section>
</main>
);
}
+81
View File
@@ -0,0 +1,81 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { TransactionList } from '@/components/TransactionList';
import { Button } from '@/components/ui';
import { api } from '@/lib/api';
import type { Transaction, PaginatedResponse } from '@/lib/types';
const PAGE_SIZE = 20;
export default function HistoryPage() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchPage = useCallback(async (currentOffset: number, append: boolean) => {
if (append) setLoadingMore(true);
else setLoading(true);
setError(null);
try {
const page = await api.get<PaginatedResponse<Transaction>>(
`/api/v1/me/transactions?limit=${PAGE_SIZE}&offset=${currentOffset}`,
);
setTransactions((prev) => (append ? [...prev, ...page.transactions] : page.transactions));
setTotal(page.total);
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка загрузки');
} finally {
setLoading(false);
setLoadingMore(false);
}
}, []);
useEffect(() => {
fetchPage(0, false);
}, [fetchPage]);
function handleLoadMore() {
const nextOffset = offset + PAGE_SIZE;
setOffset(nextOffset);
fetchPage(nextOffset, true);
}
const hasMore = transactions.length < total;
return (
<main className="mx-auto max-w-lg p-4 pb-10 pt-6">
<div className="mb-4 flex items-center gap-3">
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
← Назад
</Link>
<h1 className="text-xl font-bold text-white">История операций</h1>
</div>
{error && (
<p className="mb-4 rounded-lg bg-red-900/30 px-3 py-2 text-sm text-red-400">{error}</p>
)}
<div className="rounded-2xl bg-gray-800 p-4 ring-1 ring-gray-700">
<TransactionList transactions={transactions} isLoading={loading} />
</div>
{!loading && hasMore && (
<div className="mt-4 flex justify-center">
<Button variant="secondary" onClick={handleLoadMore} isLoading={loadingMore}>
Загрузить ещё
</Button>
</div>
)}
{!loading && !hasMore && transactions.length > 0 && (
<p className="mt-4 text-center text-xs text-gray-600">Это все операции</p>
)}
</main>
);
}
+61
View File
@@ -0,0 +1,61 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { PartnerCard } from '@/components/PartnerCard';
import { Spinner } from '@/components/ui';
import { api } from '@/lib/api';
import type { Partner } from '@/lib/types';
export default function PartnersPage() {
const [partners, setPartners] = useState<Partner[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const data = await api.get<Partner[]>('/api/v1/partners');
setPartners(data);
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка загрузки партнёров');
} finally {
setLoading(false);
}
}
load();
}, []);
return (
<main className="mx-auto max-w-lg p-4 pb-10 pt-6">
<div className="mb-4 flex items-center gap-3">
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
← Назад
</Link>
<h1 className="text-xl font-bold text-white">Партнёры</h1>
</div>
{loading && (
<div className="flex justify-center py-12">
<Spinner />
</div>
)}
{error && (
<p className="rounded-lg bg-red-900/30 px-3 py-2 text-sm text-red-400">{error}</p>
)}
{!loading && !error && partners.length === 0 && (
<p className="py-8 text-center text-sm text-gray-500">Партнёры пока не добавлены</p>
)}
{!loading && partners.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{partners.map((p) => (
<PartnerCard key={p.id} partner={p} />
))}
</div>
)}
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
'use client';
import Link from 'next/link';
import { QRDisplay } from '@/components/QRDisplay';
export default function QRPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-6">
<h1 className="text-xl font-bold text-white">Оплата поинтами</h1>
<QRDisplay />
<p className="max-w-xs text-center text-sm text-gray-500">
Покажи этот QR кассиру. Он действителен 5 минут и может быть использован только один раз.
</p>
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
← Назад
</Link>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function AdminDashboardPage() {
// TODO(notion): fetch GET /api/v1/admin/stats → display key metrics + recent transactions
return (
<main className="p-6">
<p className="text-gray-500">Admin dashboard — coming soon</p>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function GrantPage() {
// TODO(notion): form (user_id, amount, description) → POST /api/v1/admin/points/grant → confirmation
return (
<main className="p-6">
<p className="text-gray-500">Grant points — coming soon</p>
</main>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #0d0d14;
--foreground: #f1f5f9;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import localFont from 'next/font/local';
import './globals.css';
const geistSans = localFont({
src: './fonts/GeistVF.woff',
variable: '--font-geist-sans',
weight: '100 900',
});
const geistMono = localFont({
src: './fonts/GeistMonoVF.woff',
variable: '--font-geist-mono',
weight: '100 900',
});
export const metadata: Metadata = {
title: 'CU Points',
description: 'Система лояльности Центрального Университета',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function RootPage() {
redirect('/login');
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
import { useRouter } from 'next/navigation';
import { Card, Button } from '@/components/ui';
import { formatPoints } from '@/lib/utils';
interface BalanceCardProps {
balance: number;
name: string;
}
export function BalanceCard({ balance, name }: BalanceCardProps) {
const router = useRouter();
return (
<Card className="bg-gradient-to-br from-blue-700 to-blue-900 text-white ring-0 shadow-lg">
<p className="text-sm text-blue-200">{name}</p>
<p className="mt-2 text-5xl font-bold tracking-tight">{formatPoints(balance)}</p>
<p className="mt-1 text-sm text-blue-300">Доступно поинтов</p>
<Button
variant="secondary"
className="mt-5 bg-white/10 text-white hover:bg-white/20 border border-white/20"
onClick={() => router.push('/qr')}
>
Показать QR
</Button>
</Card>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Partner } from '@/lib/types';
import { Card } from '@/components/ui';
interface PartnerCardProps {
partner: Partner;
}
export function PartnerCard({ partner }: PartnerCardProps) {
return (
<Card className="flex flex-col gap-1">
<p className="font-semibold text-white">{partner.name}</p>
<p className="text-sm text-gray-400">{partner.address}</p>
<p className="mt-2 inline-flex items-center self-start rounded-full bg-blue-900/40 px-2.5 py-0.5 text-xs font-medium text-blue-300">
До {partner.max_spend_pct}% поинтами
</p>
</Card>
);
}
+96
View File
@@ -0,0 +1,96 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Button, Spinner } from '@/components/ui';
import { api } from '@/lib/api';
import type { QRResponse } from '@/lib/types';
const QR_TTL_SECONDS = 300; // 5 minutes, matches backend JWT TTL
export function QRDisplay() {
const [token, setToken] = useState<string | null>(null);
const [secondsLeft, setSecondsLeft] = useState(QR_TTL_SECONDS);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchToken = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.get<QRResponse>('/api/v1/me/qr');
setToken(data.token);
setSecondsLeft(QR_TTL_SECONDS);
} catch (e) {
setError(e instanceof Error ? e.message : 'Не удалось получить QR-код');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchToken();
}, [fetchToken]);
// Countdown tick — stops at 0.
useEffect(() => {
if (!token || secondsLeft <= 0) return;
const id = setInterval(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearInterval(id);
}, [token, secondsLeft]);
const expired = secondsLeft <= 0;
const minutes = Math.floor(secondsLeft / 60);
const seconds = secondsLeft % 60;
const countdownText = `${minutes}:${String(seconds).padStart(2, '0')}`;
if (loading) {
return (
<div className="flex flex-col items-center gap-4 py-12">
<Spinner size="lg" />
<p className="text-sm text-gray-500">Генерируем QR-код…</p>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-sm text-red-400">{error}</p>
<Button onClick={fetchToken}>Попробовать снова</Button>
</div>
);
}
return (
<div className="flex flex-col items-center gap-5">
{/* QR code always has a white bg — required for scanner contrast */}
<div
className={`rounded-2xl bg-white p-5 shadow-xl transition-opacity ${expired ? 'opacity-20' : ''}`}
>
{token && !expired ? (
<QRCodeSVG value={token} size={220} level="H" includeMargin={false} />
) : (
<div className="flex h-[220px] w-[220px] items-center justify-center">
<p className="text-sm text-gray-400">QR-код истёк</p>
</div>
)}
</div>
{!expired ? (
<p className="text-sm text-gray-400">
Действителен ещё{' '}
<span
className={`font-semibold tabular-nums ${secondsLeft < 60 ? 'text-red-400' : 'text-gray-200'}`}
>
{countdownText}
</span>
</p>
) : (
<Button onClick={fetchToken} isLoading={loading}>
Обновить
</Button>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Transaction } from '@/lib/types';
import { Badge, Spinner } from '@/components/ui';
import { formatDate, formatTransactionAmount } from '@/lib/utils';
interface TransactionListProps {
transactions: Transaction[];
isLoading: boolean;
}
export function TransactionList({ transactions, isLoading }: TransactionListProps) {
if (isLoading) {
return (
<div className="flex justify-center py-10">
<Spinner />
</div>
);
}
if (transactions.length === 0) {
return (
<p className="py-8 text-center text-sm text-gray-500">Пока нет операций</p>
);
}
return (
<ul className="divide-y divide-gray-700">
{transactions.map((tx) => (
<li key={tx.id} className="flex items-center gap-3 py-3">
<Badge type={tx.type} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-100">
{tx.description || (tx.type === 'spend' ? 'Оплата у партнёра' : 'Начисление поинтов')}
</p>
<p className="text-xs text-gray-500">{formatDate(tx.created_at)}</p>
</div>
<span
className={`shrink-0 text-sm font-semibold tabular-nums ${tx.amount >= 0 ? 'text-green-400' : 'text-red-400'}`}
>
{formatTransactionAmount(tx.amount)}
</span>
</li>
))}
</ul>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { TransactionType } from '@/lib/types';
const TYPE_LABELS: Record<TransactionType, string> = {
earn: 'Начисление',
spend: 'Списание',
admin_grant: 'Начисление',
expire: 'Сгорание',
};
const TYPE_CLASSES: Record<TransactionType, string> = {
earn: 'bg-green-900/50 text-green-400',
spend: 'bg-red-900/50 text-red-400',
admin_grant: 'bg-blue-900/50 text-blue-400',
expire: 'bg-gray-700 text-gray-400',
};
interface BadgeProps {
type: TransactionType;
}
export function Badge({ type }: BadgeProps) {
return (
<span
className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${TYPE_CLASSES[type]}`}
>
{TYPE_LABELS[type]}
</span>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { type ButtonHTMLAttributes } from 'react';
type Variant = 'primary' | 'secondary' | 'ghost';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
isLoading?: boolean;
}
const VARIANT_CLASSES: Record<Variant, string> = {
primary: 'bg-blue-600 text-white hover:bg-blue-500 disabled:bg-blue-800 disabled:text-blue-400',
secondary: 'bg-gray-700 text-gray-200 hover:bg-gray-600 disabled:opacity-50',
ghost: 'bg-transparent text-blue-400 hover:bg-gray-700 disabled:opacity-50',
};
export function Button({
variant = 'primary',
isLoading = false,
disabled,
children,
className = '',
...props
}: ButtonProps) {
return (
<button
disabled={disabled ?? isLoading}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900 ${VARIANT_CLASSES[variant]} ${className}`}
{...props}
>
{isLoading && (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
)}
{children}
</button>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { type HTMLAttributes } from 'react';
interface CardProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export function Card({ children, className = '', ...props }: CardProps) {
return (
<div
className={`rounded-2xl bg-gray-800 p-6 shadow-sm ring-1 ring-gray-700 ${className}`}
{...props}
>
{children}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { type InputHTMLAttributes, useId } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
export function Input({ label, error, className = '', ...props }: InputProps) {
const id = useId();
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className="text-sm font-medium text-gray-300">
{label}
</label>
<input
id={id}
className={`rounded-lg border bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none transition-colors focus:ring-2 ${error ? 'border-red-500 focus:border-red-500 focus:ring-red-800' : 'border-gray-600 focus:border-blue-500 focus:ring-blue-900'} ${className}`}
{...props}
/>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
interface SpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const SIZE_CLASSES = {
sm: 'h-4 w-4 border-2',
md: 'h-8 w-8 border-2',
lg: 'h-12 w-12 border-4',
};
export function Spinner({ size = 'md', className = '' }: SpinnerProps) {
return (
<div
role="status"
aria-label="Загрузка"
className={`animate-spin rounded-full border-blue-600 border-t-transparent ${SIZE_CLASSES[size]} ${className}`}
/>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { Button } from './Button';
export { Input } from './Input';
export { Card } from './Card';
export { Badge } from './Badge';
export { Spinner } from './Spinner';
+113
View File
@@ -0,0 +1,113 @@
// All API calls must go through this module — never call fetch directly in components.
// Automatically attaches the Bearer token and handles 401 → token refresh → retry.
import type { ApiError, ApiResponse } from './types';
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080';
function getAccessToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token');
}
function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('refresh_token');
}
/** Attempts to refresh the access token using the stored refresh token.
* On success: updates localStorage + cookie and returns the new token.
* On failure: returns null so the caller can redirect to /login. */
async function tryRefresh(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken) return null;
try {
const res = await fetch(`${BASE_URL}/api/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) return null;
const json = (await res.json()) as ApiResponse<{ access_token: string }>;
const newToken = json.data.access_token;
// Sync to localStorage + cookie so the middleware cookie stays valid.
localStorage.setItem('access_token', newToken);
document.cookie = `access_token=${newToken}; path=/; SameSite=Strict; max-age=900`;
// Also patch the Zustand persist entry so the store stays consistent after page reload.
try {
const raw = localStorage.getItem('cu-points-auth');
if (raw) {
const parsed = JSON.parse(raw) as { state?: { accessToken?: string } };
if (parsed.state) {
parsed.state.accessToken = newToken;
localStorage.setItem('cu-points-auth', JSON.stringify(parsed));
}
}
} catch {
// If patching the store fails it's not critical — the next setTokens call will fix it.
}
return newToken;
} catch {
return null;
}
}
interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
}
async function request<T>(
path: string,
options: RequestOptions = {},
isRetry = false,
): Promise<T> {
const token = getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers as Record<string, string> | undefined),
};
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
});
// 401: attempt refresh once, then give up and redirect to login.
if (res.status === 401 && !isRetry) {
const newToken = await tryRefresh();
if (newToken) {
return request<T>(path, options, true);
}
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Session expired. Redirecting to login.');
}
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const err = (await res.json()) as ApiError;
message = err.error ?? message;
} catch {
// response body was not JSON
}
throw new Error(message);
}
const json = (await res.json()) as ApiResponse<T>;
return json.data;
}
export const api = {
get: <T>(path: string) => request<T>(path, { method: 'GET' }),
post: <T>(path: string, body: unknown) => request<T>(path, { method: 'POST', body }),
};
+62
View File
@@ -0,0 +1,62 @@
// Global client state managed with Zustand.
// Only truly global state lives here: auth tokens and the current user profile.
// Local UI state (loading flags, form values) stays in component useState.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from './types';
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
user: User | null;
setTokens: (access: string, refresh: string) => void;
setUser: (user: User) => void;
updateBalance: (newBalance: number) => void;
logout: () => void;
}
/** Writes the access token to localStorage and to a short-lived cookie so
* Next.js middleware (edge runtime) can read it for role-based redirects. */
function persistToken(accessToken: string): void {
if (typeof window === 'undefined') return;
localStorage.setItem('access_token', accessToken);
// 15 min lifetime matches the default JWT_ACCESS_TTL
document.cookie = `access_token=${accessToken}; path=/; SameSite=Strict; max-age=900`;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
accessToken: null,
refreshToken: null,
user: null,
setTokens: (accessToken, refreshToken) => {
persistToken(accessToken);
if (typeof window !== 'undefined') {
localStorage.setItem('refresh_token', refreshToken);
}
set({ accessToken, refreshToken });
},
setUser: (user) => set({ user }),
updateBalance: (newBalance) => {
const { user } = get();
if (user) set({ user: { ...user, balance: newBalance } });
},
logout: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
// Clear the auth cookie so middleware stops treating this session as logged in.
document.cookie = 'access_token=; path=/; max-age=0';
}
set({ accessToken: null, refreshToken: null, user: null });
},
}),
{ name: 'cu-points-auth' }
)
);
+70
View File
@@ -0,0 +1,70 @@
// All API response types live here. Never use `any` — add a proper type instead.
// Field names match the backend JSON exactly (snake_case) so no conversion layer is needed.
export type TransactionType = 'earn' | 'spend' | 'admin_grant' | 'expire';
export type UserRole = 'student' | 'partner' | 'admin';
// Profile returned by GET /api/v1/me
export interface Profile {
id: string;
email: string;
name: string;
student_id: string;
balance: number;
}
// User stored in the Zustand auth store: Profile + role extracted from JWT claims.
export interface User extends Profile {
role: UserRole;
}
export interface Transaction {
id: string;
amount: number;
type: TransactionType;
description: string;
partner_id: string;
created_at: string;
}
export interface Partner {
id: string;
name: string;
address: string;
max_spend_pct: number;
}
// Token pair returned by POST /api/v1/auth/login
export interface TokenPair {
access_token: string;
refresh_token: string;
}
// Response from GET /api/v1/me/qr
export interface QRResponse {
token: string;
}
// Stats returned by GET /api/v1/admin/stats
export interface Stats {
total_students: number;
total_points_issued: number;
total_points_spent: number;
active_partners: number;
}
// Generic success envelope: every API response is wrapped in { "data": ... }
export interface ApiResponse<T> {
data: T;
}
// Shape of paginated transaction endpoints (both /me/transactions and /admin/transactions)
export interface PaginatedResponse<T> {
transactions: T[];
total: number;
}
// Error envelope: { "error": "..." }
export interface ApiError {
error: string;
}
+32
View File
@@ -0,0 +1,32 @@
// Utility helpers for formatting numbers and dates throughout the UI.
/**
* Formats a point balance for display: 1234 → "1 234 pts"
* Uses the Russian locale so thousands are separated by a space.
*/
export function formatPoints(n: number): string {
return `${new Intl.NumberFormat('ru-RU').format(n)} pts`;
}
/**
* Formats a transaction amount with a sign prefix.
* Positive amounts use a "+" prefix; negative amounts use the Unicode minus sign "−".
* Example: 50 → "+50", -30 → "−30"
*/
export function formatTransactionAmount(amount: number): string {
if (amount >= 0) return `+${amount}`;
return `−${Math.abs(amount)}`; // U+2212 MINUS SIGN, visually distinct from hyphen
}
/**
* Formats a UTC ISO timestamp to a compact Russian date+time string.
* Example: "2024-04-28T14:32:00Z" → "28 апр., 14:32"
*/
export function formatDate(iso: string): string {
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso));
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Decodes a JWT payload without verifying the signature.
// Safe for middleware because we only need the role for redirects — the backend
// will reject any tampered token when the actual API call is made.
function getJwtPayload(token: string): { role?: string; exp?: number } | null {
try {
const part = token.split('.')[1];
if (!part) return null;
// Fix base64url → base64 padding
const padded = part.padEnd(part.length + ((4 - (part.length % 4)) % 4), '=');
const decoded = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(decoded) as { role?: string; exp?: number };
} catch {
return null;
}
}
// Maps URL path prefixes to the role required to access them.
const ROLE_REQUIREMENTS: [RegExp, string][] = [
[/^\/(dashboard|history|qr|partners)/, 'student'],
[/^\/scan/, 'partner'],
[/^\/admin/, 'admin'],
];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const required = ROLE_REQUIREMENTS.find(([re]) => re.test(pathname))?.[1];
if (!required) return NextResponse.next();
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
const payload = getJwtPayload(token);
// Redirect if the token is missing, expired, or has the wrong role.
if (!payload || payload.role !== required) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (payload.exp !== undefined && payload.exp * 1000 < Date.now()) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/(dashboard|history|qr|partners|scan|admin)(.*)'],
};
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
+6102
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.35",
"qrcode.react": "^4.2.0",
"react": "^18",
"react-dom": "^18",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
+19
View File
@@ -0,0 +1,19 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
};
export default config;
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}