feat(dashboard): Next.js 16 merchant panel with offline POS and PWA
Complete merchant dashboard upgrade:
Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors
Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect
PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import axios, { type AxiosError } from "axios";
|
||||
import type { ApiResponse } from "./types";
|
||||
import { getOrCreateTerminalId } from "@/lib/terminal";
|
||||
|
||||
const baseURL =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("meezi_access_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
config.headers["X-Meezi-Terminal-Id"] = getOrCreateTerminalId();
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError<ApiResponse<unknown>>) => {
|
||||
const apiError = error.response?.data?.error;
|
||||
if (apiError?.code) {
|
||||
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
|
||||
}
|
||||
if (error.response?.status === 401 && typeof window !== "undefined") {
|
||||
const path = window.location.pathname;
|
||||
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
|
||||
const isAdmin = path.includes("/admin");
|
||||
if (!isPublicGuest && !isAdmin) {
|
||||
localStorage.removeItem("meezi_access_token");
|
||||
localStorage.removeItem("meezi_refresh_token");
|
||||
localStorage.removeItem("meezi_auth");
|
||||
const locale = path.split("/")[1] ?? "fa";
|
||||
window.location.href = `/${locale}/login`;
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export interface PagedMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface PagedApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T[];
|
||||
meta?: PagedMeta;
|
||||
error?: { code: string; message: string; field?: string };
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string): Promise<T> {
|
||||
const { data } = await api.get<ApiResponse<T>>(url);
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new Error(data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiGetPaged<T>(url: string): Promise<{ items: T[]; meta: PagedMeta }> {
|
||||
const { data } = await api.get<PagedApiResponse<T>>(url);
|
||||
if (!data.success || data.data === undefined || !data.meta) {
|
||||
throw new Error(data.error?.message ?? "Request failed");
|
||||
}
|
||||
return { items: data.data, meta: data.meta };
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiClientError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.post<ApiResponse<T>>(url, body);
|
||||
if (!data.success || data.data === undefined) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.put<ApiResponse<T>>(url, body);
|
||||
if (!data.success || data.data === undefined) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T> {
|
||||
const { data } = await api.patch<ApiResponse<T>>(url, body);
|
||||
if (!data.success || data.data === undefined) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function apiDelete(url: string): Promise<void> {
|
||||
const { data } = await api.delete<ApiResponse<unknown>>(url);
|
||||
if (!data.success) {
|
||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** GET binary response (QR PNG, Excel export, etc.) with auth headers. */
|
||||
export async function apiGetBlob(path: string): Promise<Blob> {
|
||||
const response = await api.get(path, { responseType: "blob" });
|
||||
return response.data as Blob;
|
||||
}
|
||||
|
||||
/** Public GET JSON (no auth required). */
|
||||
export async function apiGetPublic<T>(path: string): Promise<T> {
|
||||
const { data } = await api.get<ApiResponse<T>>(path);
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new ApiClientError(
|
||||
data.error?.code ?? "REQUEST_FAILED",
|
||||
data.error?.message ?? "Request failed"
|
||||
);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** Public POST JSON (no auth required). */
|
||||
export async function apiPostPublic<T, B = unknown>(path: string, body?: B): Promise<T> {
|
||||
const { data } = await api.post<ApiResponse<T>>(path, body);
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new ApiClientError(
|
||||
data.error?.code ?? "REQUEST_FAILED",
|
||||
data.error?.message ?? "Request failed"
|
||||
);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export function openBlobInNewTab(blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
|
||||
export async function apiDownload(path: string, filename: string): Promise<void> {
|
||||
const blob = await apiGetBlob(path);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export async function apiUpload<T>(url: string, file: File): Promise<T> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const { data } = await api.post<ApiResponse<T>>(url, form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
if (!data.success || data.data === undefined) {
|
||||
throw new Error(data.error?.message ?? "Upload failed");
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export function resolveMediaUrl(path?: string | null): string | undefined {
|
||||
if (!path) return undefined;
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||
const base = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
|
||||
return `${base.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
Reference in New Issue
Block a user