281 lines
9.0 KiB
TypeScript
281 lines
9.0 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);
|