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:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function RootPage() {
|
||||
redirect('/login');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { Button } from './Button';
|
||||
export { Input } from './Input';
|
||||
export { Card } from './Card';
|
||||
export { Badge } from './Badge';
|
||||
export { Spinner } from './Spinner';
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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' }
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)(.*)'],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6102
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user