feat(F-158): completed feature
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
port: 3006,
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000',
|
||||
},
|
||||
allowedDevOrigins: ['192.168.18.93', 'localhost'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import type { NextLayout } from 'next';
|
||||
|
||||
const TerminalLayout: NextLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
export default function TerminalLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default TerminalLayout;
|
||||
}
|
||||
|
||||
@@ -30,26 +30,65 @@ interface Config {
|
||||
paymentMethods: { id: string; code: string; label: string; kind: string }[];
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [configError, setConfigError] = useState('');
|
||||
const [needsBinding, setNeedsBinding] = useState(false);
|
||||
const [bindingCode, setBindingCode] = useState('');
|
||||
const [binding, setBinding] = useState(false);
|
||||
const [cart, setCart] = useState<CartItem[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<CartItem | null>(null);
|
||||
const [showDiscountPanel, setShowDiscountPanel] = useState(false);
|
||||
const [customer, setCustomer] = useState<{ id: string; email: string; firstName?: string; lastName?: string } | null>(null);
|
||||
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||
const [showCustomerSearch, setShowCustomerSearch] = useState(false);
|
||||
const [customerQuery, setCustomerQuery] = useState('');
|
||||
const [customerResults, setCustomerResults] = useState<typeof customer[]>([]);
|
||||
const [customerResults, setCustomerResults] = useState<Customer[]>([]);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [lastSale, setLastSale] = useState<{ orderId: string; totalCents: number } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
posApi.config().then(setConfig).catch(() => setConfig(null));
|
||||
const loadConfig = useCallback(async () => {
|
||||
setConfigError('');
|
||||
try {
|
||||
setConfig(await posApi.config<Config>());
|
||||
setNeedsBinding(false);
|
||||
} catch (err: unknown) {
|
||||
const apiError = err as { code?: string; message?: string };
|
||||
setConfig(null);
|
||||
setNeedsBinding(apiError.code === 'MISSING_TERMINAL_ID');
|
||||
setConfigError(apiError.message ?? 'No se pudo cargar la configuración del TPV');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const bindTerminal = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBinding(true);
|
||||
setConfigError('');
|
||||
try {
|
||||
await posApi.bind(bindingCode.trim().toUpperCase());
|
||||
setBindingCode('');
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
setConfigError(err instanceof Error ? err.message : 'No se pudo vincular el terminal');
|
||||
} finally {
|
||||
setBinding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (q.trim().length < 2) { setSearchResults([]); return; }
|
||||
setSearching(true);
|
||||
@@ -104,7 +143,7 @@ export default function RegisterPage() {
|
||||
try {
|
||||
const res = await fetch(`/api/pos/customers/search?q=${encodeURIComponent(q)}`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { items: typeof customer[] };
|
||||
const data = await res.json() as { items: Customer[] };
|
||||
setCustomerResults(data.items ?? []);
|
||||
}
|
||||
} catch { setCustomerResults([]); }
|
||||
@@ -138,15 +177,56 @@ export default function RegisterPage() {
|
||||
setCart([]);
|
||||
setCustomer(null);
|
||||
setTimeout(() => setLastSale(null), 5000);
|
||||
} catch (err: { message?: string }) {
|
||||
setError((err as { message?: string }).message ?? 'Error');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Error');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!config && needsBinding) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-6">
|
||||
<form onSubmit={bindTerminal} className="w-full max-w-sm space-y-4 rounded-2xl bg-white p-8 shadow-lg">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Vincular terminal</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">Introduce el código de 8 caracteres generado en administración.</p>
|
||||
</div>
|
||||
<input
|
||||
value={bindingCode}
|
||||
onChange={(event) => setBindingCode(event.target.value.toUpperCase())}
|
||||
minLength={8}
|
||||
maxLength={8}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-xl border border-gray-300 px-4 py-3 text-center font-mono text-xl tracking-widest outline-none focus:ring-2 focus:ring-[#2D6A4F]"
|
||||
placeholder="AB12CD34"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{configError && <p className="text-sm text-red-600">{configError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={binding || bindingCode.trim().length !== 8}
|
||||
className="w-full rounded-xl bg-[#2D6A4F] py-2.5 font-semibold text-white transition-colors hover:bg-[#1B4332] disabled:opacity-50"
|
||||
>
|
||||
{binding ? 'Vinculando…' : 'Vincular TPV'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <div className="flex items-center justify-center min-h-screen text-gray-500">Cargando TPV…</div>;
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-gray-500">
|
||||
<p>{configError || 'Cargando TPV…'}</p>
|
||||
{configError && (
|
||||
<button onClick={() => void loadConfig()} className="text-sm font-medium text-[#2D6A4F] hover:underline">
|
||||
Reintentar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.session?.status !== 'OPEN') {
|
||||
|
||||
@@ -1,32 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||
const BACKEND_URL = process.env.POS_BACKEND_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
function apiPath(request: NextRequest): string {
|
||||
return request.nextUrl.pathname.replace(/^\/api\//, '');
|
||||
}
|
||||
|
||||
function backendUrl(request: NextRequest): string {
|
||||
return `${BACKEND_URL}/${apiPath(request)}${request.nextUrl.search}`;
|
||||
}
|
||||
|
||||
function requestHeaders(request: NextRequest, hasBody = false): Headers {
|
||||
const headers = new Headers();
|
||||
const cookie = request.headers.get('cookie');
|
||||
const terminalId = request.headers.get('x-terminal-id') ?? request.cookies.get('pos_terminal_id')?.value;
|
||||
|
||||
if (cookie) headers.set('cookie', cookie);
|
||||
if (terminalId) headers.set('x-terminal-id', terminalId);
|
||||
if (hasBody) headers.set('content-type', request.headers.get('content-type') ?? 'application/json');
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function proxyResponse(backendResponse: Response): NextResponse {
|
||||
const response = new NextResponse(backendResponse.body, {
|
||||
status: backendResponse.status,
|
||||
statusText: backendResponse.statusText,
|
||||
});
|
||||
|
||||
const contentType = backendResponse.headers.get('content-type');
|
||||
const setCookie = backendResponse.headers.get('set-cookie');
|
||||
if (contentType) response.headers.set('content-type', contentType);
|
||||
if (setCookie) response.headers.set('set-cookie', setCookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
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' } });
|
||||
try {
|
||||
const response = await fetch(backendUrl(request), {
|
||||
headers: requestHeaders(request),
|
||||
cache: 'no-store',
|
||||
});
|
||||
return proxyResponse(response);
|
||||
} catch {
|
||||
return NextResponse.json({ message: 'Backend no disponible' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
try {
|
||||
const body = await request.text();
|
||||
const response = await fetch(backendUrl(request), {
|
||||
method: 'POST',
|
||||
headers: requestHeaders(request, true),
|
||||
body,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const bindingPayload = response.ok && apiPath(request) === 'pos/terminals/bind'
|
||||
? await response.clone().json() as { terminalId?: string }
|
||||
: null;
|
||||
const proxied = proxyResponse(response);
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', Cookie: cookie };
|
||||
if (terminalId) headers['x-terminal-id'] = terminalId;
|
||||
if (bindingPayload?.terminalId) {
|
||||
proxied.cookies.set('pos_terminal_id', bindingPayload.terminalId, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 365 * 24 * 60 * 60,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
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' } });
|
||||
return proxied;
|
||||
} catch {
|
||||
return NextResponse.json({ message: 'Backend no disponible' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||
// Browser requests stay on the POS origin. The Next.js route handler proxies
|
||||
// `/api/*` to the backend, so LAN clients never resolve their own localhost.
|
||||
const API = '/api';
|
||||
|
||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
@@ -10,8 +12,14 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
},
|
||||
});
|
||||
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 });
|
||||
const err = await res.json().catch(() => ({ message: res.statusText })) as {
|
||||
message?: string;
|
||||
error?: { message?: string; code?: string };
|
||||
};
|
||||
throw Object.assign(
|
||||
new Error(err.error?.message ?? err.message ?? 'Request failed'),
|
||||
{ status: res.status, code: err.error?.code },
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
@@ -21,9 +29,12 @@ export const posApi = {
|
||||
me: () => apiFetch('/pos/terminals/me'),
|
||||
/** Bind terminal with code. */
|
||||
bind: (bindingCode: string) =>
|
||||
apiFetch('/pos/terminals/bind', { method: 'POST', body: JSON.stringify({ bindingCode }) }),
|
||||
apiFetch<{ terminalId: string; storeId: string }>('/pos/terminals/bind', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ bindingCode }),
|
||||
}),
|
||||
/** Get POS config (store + terminal + payment methods + session status). */
|
||||
config: () => apiFetch('/pos/config'),
|
||||
config: <T>() => apiFetch<T>('/pos/config'),
|
||||
/** List products by query. */
|
||||
searchProducts: (q: string, storeId?: string, limit = 20) =>
|
||||
apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`),
|
||||
|
||||
@@ -11,9 +11,13 @@ export function middleware(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check for backoffice session cookie
|
||||
// The identity backend issues the mdv_session cookie. Keep legacy names
|
||||
// accepted during migration, but do not redirect valid current sessions.
|
||||
const cookie = request.headers.get('cookie') ?? '';
|
||||
const hasSession = cookie.includes('backoffice_session') || cookie.includes('session_token');
|
||||
const hasSession =
|
||||
cookie.includes('mdv_session=') ||
|
||||
cookie.includes('backoffice_session=') ||
|
||||
cookie.includes('session_token=');
|
||||
|
||||
if (!hasSession) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
|
||||
Reference in New Issue
Block a user