feat(POS-006): completed feature
This commit is contained in:
10
project/apps/pos/next.config.ts
Normal file
10
project/apps/pos/next.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
port: 3006,
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000',
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
24
project/apps/pos/package.json
Normal file
24
project/apps/pos/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "mercadodevida-pos",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3006",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3006"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0"
|
||||
}
|
||||
}
|
||||
76
project/apps/pos/src/app/(auth)/login/page.tsx
Normal file
76
project/apps/pos/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
router.push('/');
|
||||
} else {
|
||||
const data = await res.json() as { message?: string };
|
||||
setError(data.message ?? 'Login failed');
|
||||
}
|
||||
} catch {
|
||||
setError('Network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-sm">
|
||||
<h1 className="text-2xl font-bold text-center mb-6" style={{ color: 'var(--color-primary)' }}>
|
||||
Mercado de Vida
|
||||
</h1>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{loading ? 'Entrando…' : 'Entrar'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
project/apps/pos/src/app/(terminal)/layout.tsx
Normal file
7
project/apps/pos/src/app/(terminal)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextLayout } from 'next';
|
||||
|
||||
const TerminalLayout: NextLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default TerminalLayout;
|
||||
33
project/apps/pos/src/app/(terminal)/page.tsx
Normal file
33
project/apps/pos/src/app/(terminal)/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { posApi } from '@/lib/api-client';
|
||||
|
||||
export default function TerminalPage() {
|
||||
const [status, setStatus] = useState<'loading' | 'bound' | 'no-session'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
posApi.me()
|
||||
.then(() => setStatus('bound'))
|
||||
.catch(() => setStatus('no-session'));
|
||||
}, []);
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div className="flex items-center justify-center min-h-screen text-gray-500">Cargando TPV…</div>;
|
||||
}
|
||||
|
||||
if (status === 'no-session') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
|
||||
<h1 className="text-3xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>TPV sin vincular</h1>
|
||||
<p className="text-gray-500 mb-6">Usa un código de vinculación desde la admin para activar este terminal.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen text-center">
|
||||
<h1 className="text-4xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>Mercado de Vida</h1>
|
||||
<p className="text-gray-500">TPV listo. Implementa la pantalla de venta en POS-007.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
project/apps/pos/src/app/api/[...path]/route.ts
Normal file
32
project/apps/pos/src/app/api/[...path]/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname.replace('/api/', '');
|
||||
const search = request.nextUrl.search;
|
||||
const cookie = request.headers.get('cookie') ?? '';
|
||||
const terminalId = request.headers.get('x-terminal-id');
|
||||
|
||||
const headers: Record<string, string> = { Cookie: cookie };
|
||||
if (terminalId) headers['x-terminal-id'] = terminalId;
|
||||
|
||||
const res = await fetch(`${API}/${path}${search}`, { headers, credentials: 'include' });
|
||||
const body = await res.text();
|
||||
return new NextResponse(body, { status: res.status, headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' } });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname.replace('/api/', '');
|
||||
const search = request.nextUrl.search;
|
||||
const cookie = request.headers.get('cookie') ?? '';
|
||||
const terminalId = request.headers.get('x-terminal-id');
|
||||
const body = await request.text();
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', Cookie: cookie };
|
||||
if (terminalId) headers['x-terminal-id'] = terminalId;
|
||||
|
||||
const res = await fetch(`${API}/${path}${search}`, { method: 'POST', headers, body, credentials: 'include' });
|
||||
const resBody = await res.text();
|
||||
return new NextResponse(resBody, { status: res.status, headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' } });
|
||||
}
|
||||
13
project/apps/pos/src/app/globals.css
Normal file
13
project/apps/pos/src/app/globals.css
Normal file
@@ -0,0 +1,13 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-primary: #2D6A4F;
|
||||
--color-primary-dark: #1B4332;
|
||||
--color-bg: #f9fafb;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #e5e7eb;
|
||||
--color-text: #111827;
|
||||
--color-text-muted: #6b7280;
|
||||
}
|
||||
15
project/apps/pos/src/app/layout.tsx
Normal file
15
project/apps/pos/src/app/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Mercado de Vida — TPV',
|
||||
description: 'Terminal punto de venta',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="es">
|
||||
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
40
project/apps/pos/src/lib/api-client.ts
Normal file
40
project/apps/pos/src/lib/api-client.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw Object.assign(new Error((err as { message?: string }).message ?? 'Request failed'), { status: res.status });
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const posApi = {
|
||||
/** Get current terminal info. Requires x-terminal-id header set by middleware. */
|
||||
me: () => apiFetch('/pos/terminals/me'),
|
||||
/** Bind terminal with code. */
|
||||
bind: (bindingCode: string) =>
|
||||
apiFetch('/pos/terminals/bind', { method: 'POST', body: JSON.stringify({ bindingCode }) }),
|
||||
/** Get POS config (store + terminal + payment methods + session status). */
|
||||
config: () => apiFetch('/pos/config'),
|
||||
/** List products by query. */
|
||||
searchProducts: (q: string, storeId?: string, limit = 20) =>
|
||||
apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`),
|
||||
/** Get product by EAN. */
|
||||
productByEan: (ean: string) => apiFetch(`/pos/products/by-ean/${encodeURIComponent(ean)}`),
|
||||
/** Get product by SKU. */
|
||||
productBySku: (sku: string) => apiFetch(`/pos/products/by-sku/${encodeURIComponent(sku)}`),
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
login: (email: string, password: string) =>
|
||||
apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
logout: () => apiFetch('/auth/logout', { method: 'POST' }),
|
||||
};
|
||||
12
project/apps/pos/src/lib/idempotency.ts
Normal file
12
project/apps/pos/src/lib/idempotency.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** Generate a client-side idempotency key (UUID v4). */
|
||||
export function generateIdempotencyKey(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
4
project/apps/pos/src/lib/money.ts
Normal file
4
project/apps/pos/src/lib/money.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Format cents as EUR string. */
|
||||
export function formatPrice(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
15
project/apps/pos/src/lib/permissions.ts
Normal file
15
project/apps/pos/src/lib/permissions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** POS permissions used in the POS app. */
|
||||
export type PosPermission =
|
||||
| 'POS_SELL'
|
||||
| 'POS_REFUND'
|
||||
| 'POS_DISCOUNT'
|
||||
| 'POS_VOID'
|
||||
| 'POS_OPEN_REGISTER'
|
||||
| 'POS_CLOSE_REGISTER';
|
||||
|
||||
/** Map role → granted permissions. */
|
||||
export const POS_ROLE_PERMISSIONS: Record<string, PosPermission[]> = {
|
||||
admin: ['POS_SELL', 'POS_REFUND', 'POS_DISCOUNT', 'POS_VOID', 'POS_OPEN_REGISTER', 'POS_CLOSE_REGISTER'],
|
||||
pos_manager: ['POS_SELL', 'POS_REFUND', 'POS_DISCOUNT', 'POS_VOID', 'POS_OPEN_REGISTER', 'POS_CLOSE_REGISTER'],
|
||||
pos_cashier: ['POS_SELL'],
|
||||
};
|
||||
27
project/apps/pos/src/middleware.ts
Normal file
27
project/apps/pos/src/middleware.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/api/auth'];
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Allow public paths
|
||||
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check for backoffice session cookie
|
||||
const cookie = request.headers.get('cookie') ?? '';
|
||||
const hasSession = cookie.includes('backoffice_session') || cookie.includes('session_token');
|
||||
|
||||
if (!hasSession) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||
};
|
||||
11
project/apps/pos/tailwind.config.ts
Normal file
11
project/apps/pos/tailwind.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: ['./src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
19
project/apps/pos/tsconfig.json
Normal file
19
project/apps/pos/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "next.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user