2026-04-05 18:46:16 +04:00

275 lines
8.8 KiB
TypeScript

import React from 'react';
import type { AppProps } from 'next/app';
import type { ReactElement, ReactNode } from 'react';
import type { NextPage } from 'next';
import Head from 'next/head';
import { store } from '../stores/store';
import { Provider } from 'react-redux';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '../lib/queryClient';
// Import Instrument Sans font (self-hosted via Fontsource)
import '@fontsource-variable/instrument-sans';
import '@fontsource-variable/instrument-sans/wdth.css'; // Condensed width axis
import '../css/main.css';
import axios from 'axios';
import { baseURLApi } from '../config';
import { useRouter } from 'next/router';
import ErrorBoundary from '../components/ErrorBoundary';
import 'intro.js/introjs.css';
import { appWithTranslation } from 'next-i18next';
import '../i18n';
import IntroGuide from '../components/IntroGuide';
import {
appSteps,
loginSteps,
usersSteps,
rolesSteps,
} from '../stores/introSteps';
import { DownloadProvider } from '../context/DownloadContext';
import { logger } from '../lib/logger';
import { disablePresignedUrls } from '../lib/assetUrl';
/**
* Check if a URL is a presigned S3 URL
*/
const isPresignedS3Url = (url: string): boolean => {
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
};
// Initialize axios
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API
? process.env.NEXT_PUBLIC_BACK_API
: baseURLApi;
axios.defaults.headers.common['Content-Type'] = 'application/json';
// Set up axios request interceptor synchronously to ensure token is always attached
axios.interceptors.request.use(
(config) => {
if (typeof window !== 'undefined') {
const token =
sessionStorage.getItem('token') || localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else {
delete config.headers.Authorization;
}
}
return config;
},
(error) => Promise.reject(error),
);
// Set up axios response interceptor to handle 401 errors and presigned URL failures
axios.interceptors.response.use(
(response) => response,
(error) => {
if (typeof window !== 'undefined') {
const status = error?.response?.status;
const requestUrl = `${error?.config?.url || ''}`;
const isLoginRequest =
requestUrl.includes('/auth/signin/local') ||
requestUrl.includes('auth/signin/local');
// Detect presigned S3 URL failures (CORS not configured)
// Network errors (status 0) or CORS errors typically indicate S3 CORS issues
if (
isPresignedS3Url(requestUrl) &&
(!status || status === 0 || error.message?.includes('Network Error'))
) {
logger.info('[axios] Presigned URL failed, disabling presigned URLs', {
url: requestUrl.slice(0, 80),
});
disablePresignedUrls();
}
if (status === 401 && !isLoginRequest) {
// Clear stored tokens
sessionStorage.removeItem('token');
sessionStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('user');
delete axios.defaults.headers.common['Authorization'];
// Redirect to login if not already there
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
}
}
return Promise.reject(error);
},
);
export type NextPageWithLayout<P = Record<string, unknown>, IP = P> = NextPage<
P,
IP
> & {
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout || ((page) => page);
const router = useRouter();
const [stepsEnabled, setStepsEnabled] = React.useState(false);
const [stepName, setStepName] = React.useState('');
const [steps, setSteps] = React.useState([]);
// Register service worker for PWA offline support
React.useEffect(() => {
if (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
process.env.NODE_ENV === 'production'
) {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
logger.info('[PWA] Service worker registered:', {
scope: registration.scope,
});
// Check for updates periodically
setInterval(
() => {
registration.update();
},
60 * 60 * 1000,
); // Check every hour
})
.catch((error) => {
logger.error(
'[PWA] Service worker registration failed:',
error instanceof Error ? error : { error },
);
});
}
}, []);
React.useEffect(() => {
// Tour is disabled by default in generated projects.
return;
const isCompleted = (stepKey: string) => {
return localStorage.getItem(`completed_${stepKey}`) === 'true';
};
if (router.pathname === '/login' && !isCompleted('loginSteps')) {
setSteps(loginSteps);
setStepName('loginSteps');
setStepsEnabled(true);
} else if (router.pathname === '/dashboard' && !isCompleted('appSteps')) {
setTimeout(() => {
setSteps(appSteps);
setStepName('appSteps');
setStepsEnabled(true);
}, 1000);
} else if (
router.pathname === '/users/users-list' &&
!isCompleted('usersSteps')
) {
setTimeout(() => {
setSteps(usersSteps);
setStepName('usersSteps');
setStepsEnabled(true);
}, 1000);
} else if (
router.pathname === '/roles/roles-list' &&
!isCompleted('rolesSteps')
) {
setTimeout(() => {
setSteps(rolesSteps);
setStepName('rolesSteps');
setStepsEnabled(true);
}, 1000);
} else {
setSteps([]);
setStepsEnabled(false);
}
}, [router.pathname]);
const handleExit = () => {
setStepsEnabled(false);
};
const title = 'Shimahara Visual';
const description =
'Platform to build, preview (stage), and publish offline-ready interactive tours on project subdomains.';
const url = 'https://flatlogic.com/';
const image =
'https://project-screens.s3.amazonaws.com/screenshots/39215/app-hero-20260316-121031.png';
const imageWidth = '1920';
const imageHeight = '960';
return (
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<DownloadProvider>
{getLayout(
<>
<Head>
<meta name='description' content={description} />
<meta property='og:url' content={url} />
<meta property='og:site_name' content='https://flatlogic.com/' />
<meta key='og:title' property='og:title' content={title} />
<meta
key='og:description'
property='og:description'
content={description}
/>
<meta key='og:image' property='og:image' content={image} />
<meta property='og:image:type' content='image/png' />
<meta property='og:image:width' content={imageWidth} />
<meta property='og:image:height' content={imageHeight} />
<meta property='twitter:card' content='summary_large_image' />
<meta
key='twitter:title'
property='twitter:title'
content={title}
/>
<meta
key='twitter:description'
property='twitter:description'
content={description}
/>
<meta
key='twitter:image:src'
property='twitter:image:src'
content={image}
/>
<meta property='twitter:image:width' content={imageWidth} />
<meta property='twitter:image:height' content={imageHeight} />
<link key='favicon' rel='icon' href='/favicon.svg' />
<link rel='manifest' href='/manifest.json' />
<meta name='theme-color' content='#3B82F6' />
<meta name='mobile-web-app-capable' content='yes' />
<meta
name='apple-mobile-web-app-status-bar-style'
content='default'
/>
<meta name='apple-mobile-web-app-title' content='Tour Builder' />
</Head>
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
<IntroGuide
steps={steps}
stepsName={stepName}
stepsEnabled={stepsEnabled}
onExit={handleExit}
/>
</>,
)}
</DownloadProvider>
</Provider>
</QueryClientProvider>
);
}
export default appWithTranslation(MyApp);