Transfer all code to TS
@@ -1,7 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1016
ui/package-lock.json
generated
@@ -9,19 +9,28 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth0/nextjs-auth0": "^4.7.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fontsource/inter": "^5.2.6",
|
||||
"@mui/icons-material": "^7.1.2",
|
||||
"@mui/material": "^7.1.2",
|
||||
"@mui/material-nextjs": "^7.1.1",
|
||||
"@mui/system": "^7.1.1",
|
||||
"axios": "^1.10.0",
|
||||
"next": "15.3.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.3.4"
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20.19.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
21
ui/src/app/components/ClientLayoutEffect.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
|
||||
export default function ClientLayoutEffect(): null {
|
||||
useEffect(() => {
|
||||
async function fetchData(): Promise<void> {
|
||||
try {
|
||||
const res: AxiosResponse = await axios.get('/token');
|
||||
console.log('Token response:', res.data);
|
||||
} catch (error) {
|
||||
console.error('Token fetch error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData().then(() => console.log('Ok'));
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
238
ui/src/app/components/ImprinkAppBar.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AppBar,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Toolbar,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme as useMuiTheme,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Home,
|
||||
PhotoLibrary,
|
||||
ShoppingBag,
|
||||
Store,
|
||||
Dashboard,
|
||||
AdminPanelSettings,
|
||||
Api,
|
||||
BugReport,
|
||||
} from '@mui/icons-material';
|
||||
import { useUser } from '@auth0/nextjs-auth0';
|
||||
import {useState, MouseEvent, JSX} from 'react';
|
||||
import ThemeToggleButton from '@/app/components/theme/ThemeToggleButton';
|
||||
import useRoles from '@/app/components/hooks/useRoles';
|
||||
import { useTheme } from '@/app/components/theme/ThemeContext';
|
||||
|
||||
interface NavLink {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: JSX.Element;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export default function ImprinkAppBar() {
|
||||
const { user, isLoading } = useUser();
|
||||
const { isMerchant, isAdmin } = useRoles();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const theme = useMuiTheme();
|
||||
const { isDarkMode, toggleTheme } = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
const handleMenuOpen = (event: MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const navigationLinks: NavLink[] = [
|
||||
{ label: 'Home', href: '/', icon: <Home />, show: true },
|
||||
{ label: 'Gallery', href: '/gallery', icon: <PhotoLibrary />, show: true },
|
||||
{ label: 'Orders', href: '/orders', icon: <ShoppingBag />, show: true },
|
||||
{ label: 'Merchant', href: '/merchant', icon: <Store />, show: isMerchant },
|
||||
];
|
||||
|
||||
const adminLinks: NavLink[] = [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: <Dashboard />, show: isMerchant },
|
||||
{ label: 'Admin', href: '/admin', icon: <AdminPanelSettings />, show: isAdmin },
|
||||
{ label: 'Swagger', href: '/swagger', icon: <Api />, show: isAdmin },
|
||||
{ label: 'SEQ', href: '/seq', icon: <BugReport />, show: isAdmin },
|
||||
];
|
||||
|
||||
const visibleLinks = navigationLinks.filter(link => link.show);
|
||||
const visibleAdminLinks = adminLinks.filter(link => link.show);
|
||||
|
||||
const renderDesktopNavigation = () => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{visibleLinks.map(link => (
|
||||
<Button
|
||||
key={link.label}
|
||||
color="inherit"
|
||||
href={link.href}
|
||||
startIcon={link.icon}
|
||||
sx={{ minWidth: 'auto', px: 2, '&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.1)' } }}
|
||||
>
|
||||
{link.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderAdminBar = () => {
|
||||
if (!visibleAdminLinks.length || isMobile) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)',
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, justifyContent: 'center', maxWidth: '1200px', mx: 'auto' }}>
|
||||
<Typography variant="caption" sx={{ mr: 2, color: 'text.secondary', fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Admin Tools
|
||||
</Typography>
|
||||
{visibleAdminLinks.map(link => (
|
||||
<Button
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
startIcon={link.icon}
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{ minWidth: 'auto', px: 2, py: 0.5, color: 'text.primary', '&:hover': { backgroundColor: 'action.hover' } }}
|
||||
>
|
||||
{link.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMobileMenu = () => (
|
||||
<>
|
||||
<IconButton size="large" edge="start" color="inherit" aria-label="menu" onClick={handleMenuOpen} sx={{ mr: 2 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
mt: 1,
|
||||
minWidth: 200,
|
||||
'& .MuiMenuItem-root': { px: 2, py: 1.5, gap: 2 },
|
||||
},
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'left', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
|
||||
>
|
||||
<Divider />
|
||||
{[...visibleLinks, ...visibleAdminLinks].map(link => (
|
||||
<MenuItem
|
||||
key={link.label}
|
||||
onClick={handleMenuClose}
|
||||
component="a"
|
||||
href={link.href}
|
||||
sx={{ color: 'inherit', textDecoration: 'none', '&:hover': { backgroundColor: 'action.hover' } }}
|
||||
>
|
||||
{link.icon}
|
||||
<Typography variant="body2">{link.label}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<Divider />
|
||||
{user ? (
|
||||
<>
|
||||
<MenuItem sx={{ opacity: 1, '&:hover': { backgroundColor: 'transparent' }, cursor: 'default' }} onClick={e => e.preventDefault()}>
|
||||
<Avatar src={user.picture ?? undefined} alt={user.name ?? 'User'} sx={{ width: 24, height: 24 }} />
|
||||
<Typography variant="body2" noWrap>{user.name}</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={e => e.stopPropagation()}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Typography variant="body2">Theme</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<ThemeToggleButton />
|
||||
</Box>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<MenuItem component="a" href="/auth/logout" onClick={handleMenuClose} sx={{ color: 'error.main' }}>
|
||||
<Typography variant="body2">Logout</Typography>
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem component="a" href="/auth/login" onClick={handleMenuClose}>
|
||||
<Typography variant="body2">Login</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem component="a" href="/auth/login" onClick={handleMenuClose} sx={{ color: 'primary.main', fontWeight: 'bold' }}>
|
||||
<Typography variant="body2">Sign Up</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={toggleTheme}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Typography variant="body2">Theme</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>{isDarkMode ? '🌙' : '☀️'}</Box>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
{isMobile && renderMobileMenu()}
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: isMobile ? 1 : 0, mr: isMobile ? 0 : 4 }}>
|
||||
Imprink
|
||||
</Typography>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Box sx={{ flexGrow: 1 }}>{renderDesktopNavigation()}</Box>
|
||||
<ThemeToggleButton />
|
||||
{isLoading ? (
|
||||
<Box sx={{ ml: 2 }}>
|
||||
<Typography variant="body2">Loading...</Typography>
|
||||
</Box>
|
||||
) : user ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: 2 }}>
|
||||
<Avatar src={user.picture ?? undefined} alt={user.name ?? 'User'} sx={{ width: 32, height: 32, mr: 1 }} />
|
||||
<Typography variant="body2" sx={{ mr: 2, color: 'inherit' }}>{user.name}</Typography>
|
||||
<Button color="inherit" href="/auth/logout">Logout</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ ml: 2 }}>
|
||||
<Button color="inherit" href="/auth/login" sx={{ mr: 1 }}>Login</Button>
|
||||
<Button variant="contained" href="/auth/login" sx={{ bgcolor: 'primary.main', color: 'primary.contrastText', '&:hover': { bgcolor: 'primary.dark' } }}>
|
||||
Sign Up
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
{renderAdminBar()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
ui/src/app/components/hooks/useRoles.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useUser } from '@auth0/nextjs-auth0';
|
||||
import clientApi from '@/lib/clientApi';
|
||||
|
||||
interface RoleResponse {
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
export const useRoles = () => {
|
||||
const { user } = useUser();
|
||||
const [roles, setRoles] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserRoles = async () => {
|
||||
if (!user) {
|
||||
setRoles([]);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await clientApi.get<RoleResponse[]>('/users/me/roles');
|
||||
const userRoles = response.data.map(role => role.roleName.toLowerCase());
|
||||
setRoles(userRoles);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user roles:', err);
|
||||
setError(err as Error);
|
||||
setRoles([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserRoles().then(r => console.log(r));
|
||||
}, [user]);
|
||||
|
||||
const hasRole = (roleName: string): boolean => {
|
||||
return roles.includes(roleName.toLowerCase());
|
||||
};
|
||||
|
||||
const hasAnyRole = (roleNames: string[]): boolean => {
|
||||
return roleNames.some(roleName => hasRole(roleName));
|
||||
};
|
||||
|
||||
const hasAllRoles = (roleNames: string[]): boolean => {
|
||||
return roleNames.every(roleName => hasRole(roleName));
|
||||
};
|
||||
|
||||
const isMerchant = hasAnyRole(['merchant', 'admin']);
|
||||
const isAdmin = hasRole('admin');
|
||||
const isCustomer = hasRole('customer');
|
||||
|
||||
return {
|
||||
roles,
|
||||
isLoading,
|
||||
error,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
hasAllRoles,
|
||||
isMerchant,
|
||||
isAdmin,
|
||||
isCustomer,
|
||||
};
|
||||
};
|
||||
|
||||
export default useRoles;
|
||||
23
ui/src/app/components/theme/MuiThemeProvider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { darkTheme } from './darkTheme';
|
||||
import { lightTheme } from './lightTheme';
|
||||
import { useTheme } from './ThemeContext';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface MuiThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function MuiThemeProvider({ children }: MuiThemeProviderProps) {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={isDarkMode ? darkTheme : lightTheme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
74
ui/src/app/components/theme/ThemeContext.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
interface ThemeContextType {
|
||||
isDarkMode: boolean;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
interface ThemeContextProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeContextProvider({ children }: ThemeContextProviderProps) {
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme-preference');
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
const shouldBeDark = savedTheme ? savedTheme === 'dark' : systemPrefersDark;
|
||||
|
||||
setIsDarkMode(shouldBeDark);
|
||||
setIsInitialized(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
const savedTheme = localStorage.getItem('theme-preference');
|
||||
if (savedTheme) return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setIsDarkMode(e.matches);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [isInitialized]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = !isDarkMode;
|
||||
setIsDarkMode(newTheme);
|
||||
localStorage.setItem('theme-preference', newTheme ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
if (!isInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ isDarkMode, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextType {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeContextProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
26
ui/src/app/components/theme/ThemeToggleButton.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { IconButton } from '@mui/material';
|
||||
import { useTheme } from './ThemeContext';
|
||||
import {JSX} from "react";
|
||||
|
||||
export default function ThemeToggleButton(): JSX.Element {
|
||||
const { isDarkMode, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
onClick={toggleTheme}
|
||||
color="inherit"
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '8px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isDarkMode ? '🌙' : '☀️'}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
276
ui/src/app/components/theme/darkTheme.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client'
|
||||
|
||||
import { createTheme, ThemeOptions } from '@mui/material/styles';
|
||||
|
||||
export const darkTheme: ThemeOptions = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: '#6366f1',
|
||||
light: '#818cf8',
|
||||
dark: '#4f46e5',
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
secondary: {
|
||||
main: '#f59e0b',
|
||||
light: '#fbbf24',
|
||||
dark: '#d97706',
|
||||
contrastText: '#000000',
|
||||
},
|
||||
background: {
|
||||
default: '#0f0f23',
|
||||
paper: '#1a1a2e',
|
||||
},
|
||||
text: {
|
||||
primary: '#f8fafc',
|
||||
secondary: '#cbd5e1',
|
||||
},
|
||||
error: {
|
||||
main: '#ef4444',
|
||||
light: '#f87171',
|
||||
dark: '#dc2626',
|
||||
},
|
||||
warning: {
|
||||
main: '#f59e0b',
|
||||
light: '#fbbf24',
|
||||
dark: '#d97706',
|
||||
},
|
||||
info: {
|
||||
main: '#06b6d4',
|
||||
light: '#22d3ee',
|
||||
dark: '#0891b2',
|
||||
},
|
||||
success: {
|
||||
main: '#10b981',
|
||||
light: '#34d399',
|
||||
dark: '#059669',
|
||||
},
|
||||
divider: '#334155',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
h1: {
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: '-0.025em',
|
||||
},
|
||||
h2: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: '-0.025em',
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
letterSpacing: '-0.015em',
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.6,
|
||||
fontWeight: 400,
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.6,
|
||||
fontWeight: 400,
|
||||
},
|
||||
button: {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
textTransform: 'none',
|
||||
letterSpacing: '0.025em',
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
shadows: [
|
||||
'none',
|
||||
'0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
'0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
'0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
],
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: '#6366f1 #1a1a2e',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#1a1a2e',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#6366f1',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 24px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
},
|
||||
contained: {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5b21b6 0%, #7c3aed 100%)',
|
||||
},
|
||||
},
|
||||
outlined: {
|
||||
borderWidth: '1.5px',
|
||||
'&:hover': {
|
||||
borderWidth: '1.5px',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#6366f1',
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#6366f1',
|
||||
borderWidth: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(145deg, #1a1a2e 0%, #16213e 100%)',
|
||||
border: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
'&:hover': {
|
||||
border: '1px solid rgba(99, 102, 241, 0.2)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
backgroundColor: '#1a1a2e',
|
||||
border: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'rgba(26, 26, 46, 0.8)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderBottom: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
background: 'linear-gradient(180deg, #1a1a2e 0%, #16213e 100%)',
|
||||
border: 'none',
|
||||
borderRight: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'rgba(99, 102, 241, 0.1)',
|
||||
color: '#818cf8',
|
||||
border: '1px solid rgba(99, 102, 241, 0.2)',
|
||||
'&:hover': {
|
||||
background: 'rgba(99, 102, 241, 0.2)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem',
|
||||
'&.Mui-selected': {
|
||||
color: '#6366f1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTabs: {
|
||||
styleOverrides: {
|
||||
indicator: {
|
||||
background: 'linear-gradient(90deg, #6366f1, #8b5cf6)',
|
||||
height: '3px',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
276
ui/src/app/components/theme/lightTheme.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client'
|
||||
|
||||
import { createTheme, ThemeOptions } from '@mui/material/styles';
|
||||
|
||||
export const lightTheme : ThemeOptions = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#6366f1',
|
||||
light: '#818cf8',
|
||||
dark: '#4f46e5',
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
secondary: {
|
||||
main: '#f59e0b',
|
||||
light: '#fbbf24',
|
||||
dark: '#d97706',
|
||||
contrastText: '#000000',
|
||||
},
|
||||
background: {
|
||||
default: '#f8fafc',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: '#0f172a',
|
||||
secondary: '#475569',
|
||||
},
|
||||
error: {
|
||||
main: '#ef4444',
|
||||
light: '#f87171',
|
||||
dark: '#dc2626',
|
||||
},
|
||||
warning: {
|
||||
main: '#f59e0b',
|
||||
light: '#fbbf24',
|
||||
dark: '#d97706',
|
||||
},
|
||||
info: {
|
||||
main: '#06b6d4',
|
||||
light: '#22d3ee',
|
||||
dark: '#0891b2',
|
||||
},
|
||||
success: {
|
||||
main: '#10b981',
|
||||
light: '#34d399',
|
||||
dark: '#059669',
|
||||
},
|
||||
divider: '#e2e8f0',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
h1: {
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: '-0.025em',
|
||||
},
|
||||
h2: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: '-0.025em',
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
letterSpacing: '-0.015em',
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.6,
|
||||
fontWeight: 400,
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.6,
|
||||
fontWeight: 400,
|
||||
},
|
||||
button: {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
textTransform: 'none',
|
||||
letterSpacing: '0.025em',
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
shadows: [
|
||||
'none',
|
||||
'0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
'0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
'0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
'0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
],
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: '#6366f1 #f1f5f9',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#f1f5f9',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#6366f1',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 24px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
},
|
||||
contained: {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5b21b6 0%, #7c3aed 100%)',
|
||||
},
|
||||
},
|
||||
outlined: {
|
||||
borderWidth: '1.5px',
|
||||
'&:hover': {
|
||||
borderWidth: '1.5px',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'rgba(241, 245, 249, 0.5)',
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#6366f1',
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#6366f1',
|
||||
borderWidth: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(145deg, #ffffff 0%, #f8fafc 100%)',
|
||||
border: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
'&:hover': {
|
||||
border: '1px solid rgba(99, 102, 241, 0.2)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 20px 40px rgba(99, 102, 241, 0.15)',
|
||||
},
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderBottom: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.1)',
|
||||
color: '#0f172a',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
|
||||
border: 'none',
|
||||
borderRight: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'rgba(99, 102, 241, 0.7)',
|
||||
border: '1px solid rgba(99, 102, 241, 0.2)',
|
||||
'&:hover': {
|
||||
background: 'rgba(99, 102, 241, 0.9)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem',
|
||||
'&.Mui-selected': {
|
||||
color: '#6366f1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTabs: {
|
||||
styleOverrides: {
|
||||
indicator: {
|
||||
background: 'linear-gradient(90deg, #6366f1, #8b5cf6)',
|
||||
height: '3px',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 1.0 MiB |
0
ui/src/app/form/page.tsx
Normal file
720
ui/src/app/gallery/page.tsx
Normal file
@@ -0,0 +1,720 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Grid,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Pagination,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
IconButton,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
Paper,
|
||||
Slider,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
SelectChangeEvent
|
||||
} from '@mui/material';
|
||||
import {useState, useEffect, useCallback, KeyboardEvent, ChangeEvent, JSX} from 'react';
|
||||
import {
|
||||
Search,
|
||||
ExpandLess,
|
||||
ExpandMore,
|
||||
Close as CloseIcon,
|
||||
Tune as TuneIcon
|
||||
} from '@mui/icons-material';
|
||||
import clientApi from "@/lib/clientApi";
|
||||
import useRoles from "@/app/components/hooks/useRoles";
|
||||
|
||||
interface SortOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
parentCategoryId?: string;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
basePrice: number;
|
||||
imageUrl?: string;
|
||||
isCustomizable: boolean;
|
||||
category?: Category;
|
||||
}
|
||||
|
||||
interface ProductsResponse {
|
||||
items: Product[];
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
searchTerm: string;
|
||||
categoryId: string;
|
||||
minPrice: number;
|
||||
maxPrice: number;
|
||||
isActive: boolean;
|
||||
isCustomizable: boolean | null;
|
||||
sortBy: string;
|
||||
sortDirection: string;
|
||||
}
|
||||
|
||||
interface ApiParams {
|
||||
PageNumber: number;
|
||||
PageSize: number;
|
||||
IsActive: boolean;
|
||||
SortBy: string;
|
||||
SortDirection: string;
|
||||
SearchTerm?: string;
|
||||
CategoryId?: string;
|
||||
MinPrice?: number;
|
||||
MaxPrice?: number;
|
||||
IsCustomizable?: boolean;
|
||||
}
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{ value: 'Name-ASC', label: 'Name (A-Z)' },
|
||||
{ value: 'Name-DESC', label: 'Name (Z-A)' },
|
||||
{ value: 'Price-ASC', label: 'Price (Low to High)' },
|
||||
{ value: 'Price-DESC', label: 'Price (High to Low)' },
|
||||
{ value: 'CreatedDate-DESC', label: 'Newest First' },
|
||||
{ value: 'CreatedDate-ASC', label: 'Oldest First' }
|
||||
];
|
||||
|
||||
const PAGE_SIZE_OPTIONS: number[] = [12, 24, 48, 96];
|
||||
|
||||
export default function GalleryPage(): JSX.Element {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { isAdmin } = useRoles();
|
||||
|
||||
const heightOffset = isAdmin ? 192 : 128;
|
||||
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalPages, setTotalPages] = useState<number>(0);
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [categoriesLoading, setCategoriesLoading] = useState<boolean>(true);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
pageNumber: 1,
|
||||
pageSize: 24,
|
||||
searchTerm: '',
|
||||
categoryId: '',
|
||||
minPrice: 0,
|
||||
maxPrice: 1000,
|
||||
isActive: true,
|
||||
isCustomizable: null,
|
||||
sortBy: 'Name',
|
||||
sortDirection: 'ASC'
|
||||
});
|
||||
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState<boolean>(false);
|
||||
const [priceRange, setPriceRange] = useState<number[]>([0, 1000]);
|
||||
const [searchInput, setSearchInput] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCategories = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await clientApi.get<Category[]>('/products/categories');
|
||||
setCategories(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching categories:', err);
|
||||
} finally {
|
||||
setCategoriesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCategories().then(r => console.log(r));
|
||||
}, []);
|
||||
|
||||
const fetchProducts = useCallback(async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: ApiParams = {
|
||||
PageNumber: filters.pageNumber,
|
||||
PageSize: filters.pageSize,
|
||||
IsActive: filters.isActive,
|
||||
SortBy: filters.sortBy,
|
||||
SortDirection: filters.sortDirection
|
||||
};
|
||||
|
||||
if (filters.searchTerm) params.SearchTerm = filters.searchTerm;
|
||||
if (filters.categoryId) params.CategoryId = filters.categoryId;
|
||||
if (filters.minPrice > 0) params.MinPrice = filters.minPrice;
|
||||
if (filters.maxPrice < 1000) params.MaxPrice = filters.maxPrice;
|
||||
if (filters.isCustomizable !== null) params.IsCustomizable = filters.isCustomizable;
|
||||
|
||||
const response = await clientApi.get<ProductsResponse>('/products/', { params });
|
||||
|
||||
setProducts(response.data.items);
|
||||
setTotalPages(response.data.totalPages);
|
||||
setTotalCount(response.data.totalCount);
|
||||
} catch (err) {
|
||||
setError('Failed to load products');
|
||||
console.error('Error fetching products:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [fetchProducts]);
|
||||
|
||||
const handleFilterChange = <K extends keyof Filters>(key: K, value: Filters[K]): void => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
pageNumber: key !== 'pageNumber' ? 1 : (value as number)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSearch = (): void => {
|
||||
handleFilterChange('searchTerm', searchInput);
|
||||
};
|
||||
|
||||
const handlePriceRangeChange = (event: Event, newValue: number | number[]): void => {
|
||||
setPriceRange(newValue as number[]);
|
||||
};
|
||||
|
||||
const handlePriceRangeCommitted = (event: Event | React.SyntheticEvent, newValue: number | number[]): void => {
|
||||
const range = newValue as number[];
|
||||
handleFilterChange('minPrice', range[0]);
|
||||
handleFilterChange('maxPrice', range[1]);
|
||||
};
|
||||
|
||||
const handleSortChange = (value: string): void => {
|
||||
const [sortBy, sortDirection] = value.split('-');
|
||||
handleFilterChange('sortBy', sortBy);
|
||||
handleFilterChange('sortDirection', sortDirection);
|
||||
};
|
||||
|
||||
const toggleCategoryExpansion = (categoryId: string): void => {
|
||||
const newExpanded = new Set(expandedCategories);
|
||||
if (newExpanded.has(categoryId)) {
|
||||
newExpanded.delete(categoryId);
|
||||
} else {
|
||||
newExpanded.add(categoryId);
|
||||
}
|
||||
setExpandedCategories(newExpanded);
|
||||
};
|
||||
|
||||
const getChildCategories = (parentId: string): Category[] => {
|
||||
return categories.filter(cat => cat.parentCategoryId === parentId);
|
||||
};
|
||||
|
||||
const getParentCategories = (): Category[] => {
|
||||
return categories.filter(cat => !cat.parentCategoryId);
|
||||
};
|
||||
|
||||
const CategorySidebar = (): JSX.Element => (
|
||||
<Box sx={{
|
||||
width: 300,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
p: 2,
|
||||
pb: 1,
|
||||
flexShrink: 0,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
}}>
|
||||
Categories
|
||||
</Typography>
|
||||
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
px: 2,
|
||||
minHeight: 0
|
||||
}}>
|
||||
<List dense>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
selected={!filters.categoryId}
|
||||
onClick={() => handleFilterChange('categoryId', '')}
|
||||
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||
>
|
||||
<ListItemText primary="All Products" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{getParentCategories().map((category) => {
|
||||
const childCategories = getChildCategories(category.id);
|
||||
const hasChildren = childCategories.length > 0;
|
||||
const isExpanded = expandedCategories.has(category.id);
|
||||
|
||||
return (
|
||||
<Box key={category.id}>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
selected={filters.categoryId === category.id}
|
||||
onClick={() => handleFilterChange('categoryId', category.id)}
|
||||
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||
>
|
||||
<ListItemText primary={category.name} />
|
||||
{hasChildren && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCategoryExpansion(category.id);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{hasChildren && (
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{childCategories.map((childCategory) => (
|
||||
<ListItem key={childCategory.id} disablePadding sx={{ pl: 3 }}>
|
||||
<ListItemButton
|
||||
selected={filters.categoryId === childCategory.id}
|
||||
onClick={() => handleFilterChange('categoryId', childCategory.id)}
|
||||
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={childCategory.name}
|
||||
sx={{ '& .MuiListItemText-primary': { fontSize: '0.9rem' } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
flexShrink: 0,
|
||||
backgroundColor: 'background.paper'
|
||||
}}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
Price Range
|
||||
</Typography>
|
||||
<Box sx={{ px: 1, mb: 2 }}>
|
||||
<Slider
|
||||
value={priceRange}
|
||||
onChange={handlePriceRangeChange}
|
||||
onChangeCommitted={handlePriceRangeCommitted}
|
||||
valueLabelDisplay="auto"
|
||||
min={0}
|
||||
max={1000}
|
||||
step={5}
|
||||
valueLabelFormat={(value) => `$${value}`}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
|
||||
<Typography variant="caption">${priceRange[0]}</Typography>
|
||||
<Typography variant="caption">${priceRange[1]}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={filters.isCustomizable === true}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
handleFilterChange('isCustomizable', e.target.checked ? true : null)
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Customizable Only"
|
||||
sx={{ mb: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: { xs: 1, sm: 2, md: 4 } }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
gap: { xs: 0, md: 3 },
|
||||
minHeight: { xs: 'auto', md: `calc(100vh - ${heightOffset}px)` }
|
||||
}}>
|
||||
{!isMobile && (
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
height: `calc(100vh - ${heightOffset}px)`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
{categoriesLoading ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
) : (
|
||||
<CategorySidebar />
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: { xs: 'none', md: `calc(100vh - ${heightOffset}px)` }
|
||||
}}>
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
overflowY: { xs: 'visible', md: 'auto' },
|
||||
minHeight: 0,
|
||||
pr: { xs: 0, md: 1 }
|
||||
}}>
|
||||
<Box sx={{ mb: { xs: 2, sm: 2, md: 3 } }}>
|
||||
<Typography variant="h3" gutterBottom sx={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: { xs: '2rem', sm: '2rem', md: '2rem' }
|
||||
}}>
|
||||
Product Gallery
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{
|
||||
fontSize: { xs: '1rem', sm: '1rem' }
|
||||
}}>
|
||||
Explore our complete collection of customizable products
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper elevation={1} sx={{
|
||||
p: { xs: 1, sm: 1, md: 1.5 },
|
||||
mb: { xs: 1, sm: 2 }
|
||||
}}>
|
||||
<Grid container spacing={{ xs: 1, sm: 2 }} alignItems="center">
|
||||
{isMobile && (
|
||||
<Grid>
|
||||
<IconButton
|
||||
onClick={() => setMobileDrawerOpen(true)}
|
||||
sx={{ mr: 1 }}
|
||||
size="small"
|
||||
>
|
||||
<TuneIcon />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid size={{ xs:12, sm:6, md:4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search products..."
|
||||
value={searchInput}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchInput(e.target.value)}
|
||||
onKeyPress={(e: KeyboardEvent) => e.key === 'Enter' && handleSearch()}
|
||||
size={isMobile ? "small" : "medium"}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search fontSize={isMobile ? "small" : "medium"} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchInput && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleSearch}
|
||||
edge="end"
|
||||
>
|
||||
<Search fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs:6, sm:3, md:3 }}>
|
||||
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
|
||||
<InputLabel>Sort By</InputLabel>
|
||||
<Select
|
||||
value={`${filters.sortBy}-${filters.sortDirection}`}
|
||||
label="Sort By"
|
||||
onChange={(e: SelectChangeEvent) => handleSortChange(e.target.value)}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs:6, sm:3, md:2 }}>
|
||||
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
|
||||
<InputLabel>Per Page</InputLabel>
|
||||
<Select
|
||||
value={filters.pageSize}
|
||||
label="Per Page"
|
||||
onChange={(e: SelectChangeEvent<number>) =>
|
||||
handleFilterChange('pageSize', e.target.value as number)
|
||||
}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<MenuItem key={size} value={size}>
|
||||
{size}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs:12, md:3 }} sx={{ textAlign: { xs: 'center', md: 'right' } }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontSize: { xs: '0.75rem', sm: '0.875rem' }
|
||||
}}>
|
||||
Showing {products.length} of {totalCount} products
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: { xs: 4, md: 8 } }}>
|
||||
<CircularProgress size={isMobile ? 40 : 60} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: { xs: 2, md: 4 } }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && products.length === 0 && (
|
||||
<Paper sx={{ p: { xs: 3, md: 6 }, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No products found
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Try adjusting your search criteria or filters
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!loading && !error && products.length > 0 && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: { xs: 1, sm: 1.5, md: 2 },
|
||||
mb: { xs: 2, md: 4 }
|
||||
}}>
|
||||
{products.map((product) => (
|
||||
<Card
|
||||
key={product.id}
|
||||
sx={{
|
||||
width: {
|
||||
xs: 'calc(50% - 4px)',
|
||||
sm: 'calc(50% - 12px)',
|
||||
lg: 'calc(33.333% - 16px)'
|
||||
},
|
||||
maxWidth: { xs: 'none', sm: 350, lg: 370 },
|
||||
height: { xs: 300, sm: 380, lg: 420 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: { xs: 'none', sm: 'translateY(-4px)', md: 'translateY(-8px)' },
|
||||
boxShadow: { xs: 2, sm: 4, md: 6 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={product.imageUrl || '/placeholder-product.jpg'}
|
||||
alt={product.name}
|
||||
sx={{
|
||||
objectFit: 'cover',
|
||||
height: { xs: 120, sm: 160, lg: 180 }
|
||||
}}
|
||||
/>
|
||||
<CardContent sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
p: { xs: 1, sm: 1.5, lg: 2 },
|
||||
'&:last-child': { pb: { xs: 1, sm: 1.5, lg: 2 } }
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{
|
||||
fontWeight: 'bold',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: { xs: '0.9rem', sm: '1rem', md: '1.1rem' },
|
||||
mb: { xs: 0.5, sm: 1 }
|
||||
}}>
|
||||
{product.name}
|
||||
</Typography>
|
||||
|
||||
{product.category && (
|
||||
<Chip
|
||||
label={product.category.name}
|
||||
size="small"
|
||||
color="secondary"
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
mb: { xs: 0.5, sm: 1 },
|
||||
fontSize: { xs: '0.65rem', sm: '0.75rem' },
|
||||
height: { xs: 20, sm: 24 }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
flexGrow: 1,
|
||||
mb: { xs: 1, sm: 1.5 },
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: { xs: 2, sm: 2, md: 3 },
|
||||
WebkitBoxOrient: 'vertical',
|
||||
minHeight: { xs: 28, sm: 32, md: 48 },
|
||||
fontSize: { xs: '0.75rem', sm: '0.8rem', md: '0.875rem' }
|
||||
}}>
|
||||
{product.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: { xs: 1, sm: 1.5 }
|
||||
}}>
|
||||
<Typography variant="subtitle1" color="primary" sx={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: { xs: '0.85rem', sm: '0.95rem', md: '1rem' }
|
||||
}}>
|
||||
From ${product.basePrice?.toFixed(2)}
|
||||
</Typography>
|
||||
{product.isCustomizable && (
|
||||
<Chip
|
||||
label="Custom"
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: { xs: '0.65rem', sm: '0.75rem' },
|
||||
height: { xs: 20, sm: 24 }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
size={isMobile ? "small" : "medium"}
|
||||
sx={{
|
||||
fontSize: { xs: '0.75rem', sm: '0.875rem', md: '1rem' },
|
||||
py: { xs: 0.5, sm: 1, md: 1.5 }
|
||||
}}
|
||||
>
|
||||
Customize
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && !error && totalPages > 1 && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
pb: { xs: 2, md: 4 }
|
||||
}}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={filters.pageNumber}
|
||||
onChange={(e, page) => handleFilterChange('pageNumber', page)}
|
||||
color="primary"
|
||||
size={isMobile ? 'small' : 'large'}
|
||||
showFirstButton={!isMobile}
|
||||
showLastButton={!isMobile}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Drawer
|
||||
anchor="left"
|
||||
open={mobileDrawerOpen}
|
||||
onClose={() => setMobileDrawerOpen(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: 280,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', p: 2, borderBottom: 1, borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
|
||||
Filters
|
||||
</Typography>
|
||||
<IconButton onClick={() => setMobileDrawerOpen(false)}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{categoriesLoading ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
) : (
|
||||
<CategorySidebar />
|
||||
)}
|
||||
</Drawer>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -1,35 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import React from "react";
|
||||
import { Inter } from 'next/font/google';
|
||||
import { Metadata } from 'next';
|
||||
import { ReactNode } from 'react';
|
||||
import MuiThemeProvider from './components/theme/MuiThemeProvider';
|
||||
import { ThemeContextProvider } from './components/theme/ThemeContext';
|
||||
import { AppRouterCacheProvider } from "@mui/material-nextjs/v13-appRouter";
|
||||
import ImprinkAppBar from "@/app/components/ImprinkAppBar";
|
||||
import ClientLayoutEffect from "@/app/components/ClientLayoutEffect";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: 'Imprink',
|
||||
description: 'Turn your dreams into colorful realities!',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<AppRouterCacheProvider>
|
||||
<ThemeContextProvider>
|
||||
<MuiThemeProvider>
|
||||
<ClientLayoutEffect/>
|
||||
<ImprinkAppBar />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
</ThemeContextProvider>
|
||||
</AppRouterCacheProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +1,447 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Grid,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {useState, useEffect, JSX} from 'react';
|
||||
import { ShoppingCart, Palette, ImageOutlined, CreditCard, LocalShipping, CheckCircle } from '@mui/icons-material';
|
||||
import clientApi from "@/lib/clientApi";
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl?: string;
|
||||
basePrice: number;
|
||||
isCustomizable: boolean;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
items: Product[];
|
||||
}
|
||||
|
||||
interface Step {
|
||||
number: number;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: JSX.Element;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export default function HomePage(): JSX.Element {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProducts = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await clientApi.get<ApiResponse>('/products/', {
|
||||
params: {
|
||||
PageSize: 3,
|
||||
PageNumber: 1,
|
||||
IsActive: true,
|
||||
SortBy: 'Price',
|
||||
SortDirection: 'DESC'
|
||||
}
|
||||
});
|
||||
setProducts(response.data.items);
|
||||
} catch (err) {
|
||||
setError('Failed to load products');
|
||||
console.error('Error fetching products:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
number: 1,
|
||||
label: 'Pick an Item',
|
||||
description: 'Browse our extensive collection of customizable products and select the perfect base for your design. From premium t-shirts and hoodies to mugs, phone cases, and more - we have everything you need to bring your vision to life.',
|
||||
icon: <ShoppingCart sx={{ fontSize: 80 }} />,
|
||||
details: 'Explore hundreds of high-quality products across multiple categories. Filter by material, size, color, and price to find exactly what you\'re looking for.'
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
label: 'Choose Variant',
|
||||
description: 'Select from available sizes, colors, and material options that match your preferences and needs. Each product comes with detailed specifications and sizing guides.',
|
||||
icon: <Palette sx={{ fontSize: 80 }} />,
|
||||
details: 'View real-time previews of different variants. Check material quality, durability ratings, and care instructions for each option.'
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
label: 'Customize with Images',
|
||||
description: 'Upload your own designs, add custom text, or use our intuitive design tools to create something truly unique. Our editor supports various file formats and offers professional design features.',
|
||||
icon: <ImageOutlined sx={{ fontSize: 80 }} />,
|
||||
details: 'Drag and drop images, adjust positioning, add filters, create text overlays, and preview your design in real-time on the selected product.'
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
label: 'Pay',
|
||||
description: 'Complete your order with our secure checkout process. We accept multiple payment methods and provide instant order confirmation with detailed receipts.',
|
||||
icon: <CreditCard sx={{ fontSize: 80 }} />,
|
||||
details: 'Review your design, confirm quantities, apply discount codes, and choose from various secure payment options including cards, PayPal, and more.'
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
label: 'Wait for Order',
|
||||
description: 'Sit back and relax while we handle the rest. Our professional printing team will carefully produce your custom item and ship it directly to your door.',
|
||||
icon: <LocalShipping sx={{ fontSize: 80 }} />,
|
||||
details: 'Track your order status in real-time, from printing to packaging to shipping. Receive updates via email and SMS throughout the process.'
|
||||
}
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Grid container spacing={6} alignItems="center" sx={{ mb: 6, minHeight: '70vh' }}>
|
||||
<Grid size={{ xs:12, md:7 }}>
|
||||
<Box sx={{ textAlign: { xs: 'center', md: 'left' } }}>
|
||||
<Typography
|
||||
variant="h1"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 3,
|
||||
fontSize: { xs: '2.5rem', md: '3.5rem', lg: '4rem' },
|
||||
lineHeight: 1.2
|
||||
}}
|
||||
>
|
||||
Custom Printing<br />
|
||||
<Box component="span" sx={{ color: 'primary.main' }}>Made Simple</Box>
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mb: 4,
|
||||
lineHeight: 1.6,
|
||||
fontSize: { xs: '1.2rem', md: '1.4rem' }
|
||||
}}
|
||||
>
|
||||
Transform your ideas into reality with our premium custom printing services.
|
||||
From t-shirts to mugs, we bring your designs to life with professional quality
|
||||
and lightning-fast turnaround times.
|
||||
</Typography>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs:12, sm: 6 }} >
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<CheckCircle sx={{ color: 'primary.main', mr: 2 }} />
|
||||
<Typography variant="body1" fontWeight="medium">
|
||||
Professional Quality Guaranteed
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm: 6 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<CheckCircle sx={{ color: 'primary.main', mr: 2 }} />
|
||||
<Typography variant="body1" fontWeight="medium">
|
||||
Fast 24-48 Hour Turnaround
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm: 6 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<CheckCircle sx={{ color: 'primary.main', mr: 2 }} />
|
||||
<Typography variant="body1" fontWeight="medium">
|
||||
Free Design Support
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs:12, sm: 6 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<CheckCircle sx={{ color: 'primary.main', mr: 2 }} />
|
||||
<Typography variant="body1" fontWeight="medium">
|
||||
100% Satisfaction Promise
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: { xs: 'center', md: 'flex-start' }, flexWrap: 'wrap', mb: 4 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
href="/gallery"
|
||||
sx={{ px: 5, py: 2, fontSize: '1.2rem' }}
|
||||
>
|
||||
View Gallery
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
⭐⭐⭐⭐⭐ Trusted by 10,000+ customers • 4.9/5 rating
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mb: 12 }}>
|
||||
<Box sx={{ mb: 6, textAlign: { xs: 'center', md: 'left' } }}>
|
||||
<Typography variant="h2" gutterBottom sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
Featured Products
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ maxWidth: 600 }}>
|
||||
Discover our most popular customizable products. Each item is carefully selected
|
||||
for quality and perfect for personalization.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress size={60} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 4 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 3,
|
||||
justifyContent: { xs: 'center', lg: 'flex-start' }
|
||||
}}>
|
||||
{products.map((product: Product) => (
|
||||
<Card
|
||||
key={product.id}
|
||||
sx={{
|
||||
width: { xs: '100%', sm: 'calc(50% - 12px)', lg: 'calc(33.333% - 16px)' },
|
||||
maxWidth: { xs: 400, sm: 350, lg: 370 },
|
||||
height: { xs: 450, sm: 480, lg: 500 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-8px)',
|
||||
boxShadow: 6
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={product.imageUrl || '/placeholder-product.jpg'}
|
||||
alt={product.name}
|
||||
sx={{
|
||||
objectFit: 'cover',
|
||||
height: { xs: 180, sm: 200, lg: 220 }
|
||||
}}
|
||||
/>
|
||||
<CardContent sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
p: { xs: 2, sm: 2.5, lg: 3 },
|
||||
'&:last-child': { pb: { xs: 2, sm: 2.5, lg: 3 } }
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{
|
||||
fontWeight: 'bold',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: { xs: '1.1rem', sm: '1.25rem' }
|
||||
}}>
|
||||
{product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
flexGrow: 1,
|
||||
mb: 2,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: { xs: 2, sm: 3 },
|
||||
WebkitBoxOrient: 'vertical',
|
||||
minHeight: { xs: 36, sm: 54 },
|
||||
fontSize: { xs: '0.875rem', sm: '0.875rem' }
|
||||
}}>
|
||||
{product.description}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" color="primary" sx={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: { xs: '1rem', sm: '1.1rem' }
|
||||
}}>
|
||||
From ${product.basePrice?.toFixed(2)}
|
||||
</Typography>
|
||||
{product.isCustomizable && (
|
||||
<Chip
|
||||
label="Custom"
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
size="medium"
|
||||
sx={{
|
||||
fontSize: { xs: '0.875rem', sm: '1rem' },
|
||||
py: { xs: 1, sm: 1.5 }
|
||||
}}
|
||||
>
|
||||
Customize
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 12, py: 8 }}>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ mb: 8 }}>
|
||||
<Typography variant="h2" gutterBottom sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
How It Works
|
||||
</Typography>
|
||||
<Typography variant="h5" color="text.secondary" sx={{ maxWidth: 600 }}>
|
||||
Our streamlined process makes custom printing simple and stress-free.
|
||||
Follow these five easy steps to get your perfect custom products.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={6}>
|
||||
{steps.map((step: Step, index: number) => (
|
||||
<Grid size={{ xs:12 }} key={index}>
|
||||
<Card
|
||||
sx={{
|
||||
p: 4,
|
||||
boxShadow: 2,
|
||||
borderRadius: 3,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
boxShadow: 8,
|
||||
transform: 'translateY(-4px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4} alignItems="center">
|
||||
<Grid size={{ xs:12, md: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: { xs: 'center', md: 'flex-start' },
|
||||
mb: { xs: 2, md: 0 }
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'primary.main',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
mr: 2
|
||||
}}
|
||||
>
|
||||
{step.icon}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'primary.main',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{step.number}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs:12, md: 10 }}>
|
||||
<Box sx={{ textAlign: { xs: 'center', md: 'left' } }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: 'primary.main',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
{step.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
paragraph
|
||||
sx={{
|
||||
mb: 3,
|
||||
lineHeight: 1.6,
|
||||
color: 'text.primary',
|
||||
fontWeight: 400
|
||||
}}
|
||||
>
|
||||
{step.description}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
lineHeight: 1.7,
|
||||
fontSize: '1.1rem'
|
||||
}}
|
||||
>
|
||||
{step.details}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center', py: 8, bgcolor: 'background.paper', borderRadius: 2, boxShadow: 1 }}>
|
||||
<Typography variant="h3" gutterBottom sx={{ fontWeight: 'bold', mb: 3 }}>
|
||||
Ready to Get Started?
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}>
|
||||
Join thousands of satisfied customers who trust us with their custom printing needs.
|
||||
Quality guaranteed, fast turnaround, and competitive prices.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
sx={{ px: 6, py: 2, fontSize: '1.2rem' }}
|
||||
startIcon={<CheckCircle />}
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
Start Your Order Today
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
23
ui/src/app/token/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth0 } from '@/lib/auth0';
|
||||
import serverApi from '@/lib/serverApi';
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
const session = await auth0.getSession();
|
||||
const token = session?.tokenSet?.accessToken;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'No access token found' }, { status: 401 });
|
||||
}
|
||||
|
||||
await serverApi.post('/users/me/sync', null, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
return NextResponse.json("Ok");
|
||||
} catch (error) {
|
||||
console.error('Error in /serverApi/token:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
8
ui/src/lib/auth0.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Auth0Client } from "@auth0/nextjs-auth0/server";
|
||||
|
||||
export const auth0 = new Auth0Client({
|
||||
authorizationParameters: {
|
||||
scope: process.env.AUTH0_SCOPE,
|
||||
audience: process.env.AUTH0_AUDIENCE
|
||||
}
|
||||
});
|
||||
36
ui/src/lib/clientApi.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import axios, { InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
const clientApi = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
clientApi.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> => {
|
||||
if (typeof window === 'undefined') return config;
|
||||
|
||||
try {
|
||||
const res = await fetch('/auth/access-token');
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch token');
|
||||
return config;
|
||||
}
|
||||
|
||||
const data: { token?: string } = await res.json();
|
||||
|
||||
if (data.token) {
|
||||
config.headers.set('Authorization', `Bearer ${data.token}`);
|
||||
} else {
|
||||
console.warn('No token received from /auth/access-token');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching token:', err);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
export default clientApi;
|
||||
8
ui/src/lib/serverApi.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import axios from "axios";
|
||||
|
||||
const serverApi = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export default serverApi;
|
||||
12
ui/src/middleware.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { auth0 } from './lib/auth0';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function middleware(request: NextRequest): Promise<NextResponse | undefined> {
|
||||
return await auth0.middleware(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
|
||||
],
|
||||
};
|
||||
|
Before Width: | Height: | Size: 1.0 MiB |