Dev #14
@@ -40,11 +40,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
webui:
|
ui:
|
||||||
image: node:18-alpine
|
image: node:18-alpine
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
volumes:
|
volumes:
|
||||||
- ./webui:/app
|
- ./ui:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "3000"
|
- "3000"
|
||||||
@@ -93,7 +93,7 @@ services:
|
|||||||
- ./ssl/impr.ink-key.pem:/etc/ssl/private/impr.ink.key:ro
|
- ./ssl/impr.ink-key.pem:/etc/ssl/private/impr.ink.key:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- webapi
|
- webapi
|
||||||
- webui
|
- ui
|
||||||
- seq
|
- seq
|
||||||
- upload-server
|
- upload-server
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ http {
|
|||||||
server webapi:8080;
|
server webapi:8080;
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream webui {
|
upstream ui {
|
||||||
server webui:3000;
|
server ui:3000;
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream seq {
|
upstream seq {
|
||||||
@@ -120,7 +120,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://webui/;
|
proxy_pass http://ui/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
0
webui/.gitignore → ui/.gitignore
vendored
0
webui/.gitignore → ui/.gitignore
vendored
@@ -1,4 +1,4 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ bun dev
|
|||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
16
ui/eslint.config.mjs
Normal file
16
ui/eslint.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
5
ui/next.config.ts
Normal file
5
ui/next.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
7119
ui/package-lock.json
generated
Normal file
7119
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
ui/package.json
Normal file
36
ui/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20.19.1",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.3.4",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
@@ -1,21 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
export default function ClientLayoutEffect() {
|
export default function ClientLayoutEffect(): null {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/token');
|
const res: AxiosResponse = await axios.get('/token');
|
||||||
console.log('Token response:', res.data);
|
console.log('Token response:', res.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token fetch error:', error);
|
console.error('Token fetch error:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData().then(r => console.log("Ok"));
|
fetchData().then(() => console.log('Ok'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
238
ui/src/app/components/ImprinkAppBar.tsx
Normal file
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()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,15 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useUser } from '@auth0/nextjs-auth0';
|
import { useUser } from '@auth0/nextjs-auth0';
|
||||||
import clientApi from '@/lib/clientApi';
|
import clientApi from '@/lib/clientApi';
|
||||||
|
|
||||||
|
interface RoleResponse {
|
||||||
|
roleName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const useRoles = () => {
|
export const useRoles = () => {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const [roles, setRoles] = useState([]);
|
const [roles, setRoles] = useState<string[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserRoles = async () => {
|
const fetchUserRoles = async () => {
|
||||||
@@ -20,12 +24,12 @@ export const useRoles = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await clientApi.get('/users/me/roles');
|
const response = await clientApi.get<RoleResponse[]>('/users/me/roles');
|
||||||
const userRoles = response.data.map(role => role.roleName.toLowerCase());
|
const userRoles = response.data.map(role => role.roleName.toLowerCase());
|
||||||
setRoles(userRoles);
|
setRoles(userRoles);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch user roles:', err);
|
console.error('Failed to fetch user roles:', err);
|
||||||
setError(err);
|
setError(err as Error);
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -35,19 +39,18 @@ export const useRoles = () => {
|
|||||||
fetchUserRoles().then(r => console.log(r));
|
fetchUserRoles().then(r => console.log(r));
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const hasRole = (roleName) => {
|
const hasRole = (roleName: string): boolean => {
|
||||||
return roles.includes(roleName.toLowerCase());
|
return roles.includes(roleName.toLowerCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasAnyRole = (roleNames) => {
|
const hasAnyRole = (roleNames: string[]): boolean => {
|
||||||
return roleNames.some(roleName => hasRole(roleName));
|
return roleNames.some(roleName => hasRole(roleName));
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasAllRoles = (roleNames) => {
|
const hasAllRoles = (roleNames: string[]): boolean => {
|
||||||
return roleNames.every(roleName => hasRole(roleName));
|
return roleNames.every(roleName => hasRole(roleName));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Common role checks based on your existing logic
|
|
||||||
const isMerchant = hasAnyRole(['merchant', 'admin']);
|
const isMerchant = hasAnyRole(['merchant', 'admin']);
|
||||||
const isAdmin = hasRole('admin');
|
const isAdmin = hasRole('admin');
|
||||||
const isCustomer = hasRole('customer');
|
const isCustomer = hasRole('customer');
|
||||||
@@ -61,8 +64,8 @@ export const useRoles = () => {
|
|||||||
hasAllRoles,
|
hasAllRoles,
|
||||||
isMerchant,
|
isMerchant,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isCustomer
|
isCustomer,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useRoles;
|
export default useRoles;
|
||||||
@@ -5,8 +5,13 @@ import CssBaseline from '@mui/material/CssBaseline';
|
|||||||
import { darkTheme } from './darkTheme';
|
import { darkTheme } from './darkTheme';
|
||||||
import { lightTheme } from './lightTheme';
|
import { lightTheme } from './lightTheme';
|
||||||
import { useTheme } from './ThemeContext';
|
import { useTheme } from './ThemeContext';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
export default function MuiThemeProvider({ children }) {
|
interface MuiThemeProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MuiThemeProvider({ children }: MuiThemeProviderProps) {
|
||||||
const { isDarkMode } = useTheme();
|
const { isDarkMode } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -15,4 +20,4 @@ export default function MuiThemeProvider({ children }) {
|
|||||||
{children}
|
{children}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
74
ui/src/app/components/theme/ThemeContext.tsx
Normal file
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;
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { IconButton } from '@mui/material';
|
import { IconButton } from '@mui/material';
|
||||||
import { useTheme } from './ThemeContext';
|
import { useTheme } from './ThemeContext';
|
||||||
|
import {JSX} from "react";
|
||||||
|
|
||||||
export default function ThemeToggleButton() {
|
export default function ThemeToggleButton(): JSX.Element {
|
||||||
const { isDarkMode, toggleTheme } = useTheme();
|
const { isDarkMode, toggleTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,4 +23,4 @@ export default function ThemeToggleButton() {
|
|||||||
{isDarkMode ? '🌙' : '☀️'}
|
{isDarkMode ? '🌙' : '☀️'}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { createTheme } from '@mui/material/styles';
|
import { createTheme, ThemeOptions } from '@mui/material/styles';
|
||||||
|
|
||||||
export const darkTheme = createTheme({
|
export const darkTheme: ThemeOptions = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'dark',
|
mode: 'dark',
|
||||||
primary: {
|
primary: {
|
||||||
@@ -21,9 +21,6 @@ export const darkTheme = createTheme({
|
|||||||
default: '#0f0f23',
|
default: '#0f0f23',
|
||||||
paper: '#1a1a2e',
|
paper: '#1a1a2e',
|
||||||
},
|
},
|
||||||
surface: {
|
|
||||||
main: '#16213e',
|
|
||||||
},
|
|
||||||
text: {
|
text: {
|
||||||
primary: '#f8fafc',
|
primary: '#f8fafc',
|
||||||
secondary: '#cbd5e1',
|
secondary: '#cbd5e1',
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { createTheme } from '@mui/material/styles';
|
import { createTheme, ThemeOptions } from '@mui/material/styles';
|
||||||
|
|
||||||
export const lightTheme = createTheme({
|
export const lightTheme : ThemeOptions = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'light',
|
mode: 'light',
|
||||||
primary: {
|
primary: {
|
||||||
@@ -21,9 +21,6 @@ export const lightTheme = createTheme({
|
|||||||
default: '#f8fafc',
|
default: '#f8fafc',
|
||||||
paper: '#ffffff',
|
paper: '#ffffff',
|
||||||
},
|
},
|
||||||
surface: {
|
|
||||||
main: '#f1f5f9',
|
|
||||||
},
|
|
||||||
text: {
|
text: {
|
||||||
primary: '#0f172a',
|
primary: '#0f172a',
|
||||||
secondary: '#475569',
|
secondary: '#475569',
|
||||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
0
ui/src/app/form/page.tsx
Normal file
0
ui/src/app/form/page.tsx
Normal file
@@ -31,9 +31,10 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Slider,
|
Slider,
|
||||||
Switch,
|
Switch,
|
||||||
FormControlLabel
|
FormControlLabel,
|
||||||
|
SelectChangeEvent
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import {useState, useEffect, useCallback, KeyboardEvent, ChangeEvent, JSX} from 'react';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
ExpandLess,
|
ExpandLess,
|
||||||
@@ -44,7 +45,60 @@ import {
|
|||||||
import clientApi from "@/lib/clientApi";
|
import clientApi from "@/lib/clientApi";
|
||||||
import useRoles from "@/app/components/hooks/useRoles";
|
import useRoles from "@/app/components/hooks/useRoles";
|
||||||
|
|
||||||
const SORT_OPTIONS = [
|
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-ASC', label: 'Name (A-Z)' },
|
||||||
{ value: 'Name-DESC', label: 'Name (Z-A)' },
|
{ value: 'Name-DESC', label: 'Name (Z-A)' },
|
||||||
{ value: 'Price-ASC', label: 'Price (Low to High)' },
|
{ value: 'Price-ASC', label: 'Price (Low to High)' },
|
||||||
@@ -53,26 +107,26 @@ const SORT_OPTIONS = [
|
|||||||
{ value: 'CreatedDate-ASC', label: 'Oldest First' }
|
{ value: 'CreatedDate-ASC', label: 'Oldest First' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [12, 24, 48, 96];
|
const PAGE_SIZE_OPTIONS: number[] = [12, 24, 48, 96];
|
||||||
|
|
||||||
export default function GalleryPage() {
|
export default function GalleryPage(): JSX.Element {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
const { isAdmin } = useRoles();
|
const { isAdmin } = useRoles();
|
||||||
|
|
||||||
const heightOffset = isAdmin ? 192 : 128;
|
const heightOffset = isAdmin ? 192 : 128;
|
||||||
|
|
||||||
const [products, setProducts] = useState([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState<number>(0);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
|
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [categoriesLoading, setCategoriesLoading] = useState(true);
|
const [categoriesLoading, setCategoriesLoading] = useState<boolean>(true);
|
||||||
const [expandedCategories, setExpandedCategories] = useState(new Set());
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState<Filters>({
|
||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
pageSize: 24,
|
pageSize: 24,
|
||||||
searchTerm: '',
|
searchTerm: '',
|
||||||
@@ -85,14 +139,14 @@ export default function GalleryPage() {
|
|||||||
sortDirection: 'ASC'
|
sortDirection: 'ASC'
|
||||||
});
|
});
|
||||||
|
|
||||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState<boolean>(false);
|
||||||
const [priceRange, setPriceRange] = useState([0, 1000]);
|
const [priceRange, setPriceRange] = useState<number[]>([0, 1000]);
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCategories = async () => {
|
const fetchCategories = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await clientApi.get('/products/categories');
|
const response = await clientApi.get<Category[]>('/products/categories');
|
||||||
setCategories(response.data);
|
setCategories(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching categories:', err);
|
console.error('Error fetching categories:', err);
|
||||||
@@ -101,13 +155,13 @@ export default function GalleryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchCategories();
|
fetchCategories().then(r => console.log(r));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchProducts = useCallback(async () => {
|
const fetchProducts = useCallback(async (): Promise<void> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params: ApiParams = {
|
||||||
PageNumber: filters.pageNumber,
|
PageNumber: filters.pageNumber,
|
||||||
PageSize: filters.pageSize,
|
PageSize: filters.pageSize,
|
||||||
IsActive: filters.isActive,
|
IsActive: filters.isActive,
|
||||||
@@ -121,7 +175,7 @@ export default function GalleryPage() {
|
|||||||
if (filters.maxPrice < 1000) params.MaxPrice = filters.maxPrice;
|
if (filters.maxPrice < 1000) params.MaxPrice = filters.maxPrice;
|
||||||
if (filters.isCustomizable !== null) params.IsCustomizable = filters.isCustomizable;
|
if (filters.isCustomizable !== null) params.IsCustomizable = filters.isCustomizable;
|
||||||
|
|
||||||
const response = await clientApi.get('/products/', { params });
|
const response = await clientApi.get<ProductsResponse>('/products/', { params });
|
||||||
|
|
||||||
setProducts(response.data.items);
|
setProducts(response.data.items);
|
||||||
setTotalPages(response.data.totalPages);
|
setTotalPages(response.data.totalPages);
|
||||||
@@ -138,34 +192,35 @@ export default function GalleryPage() {
|
|||||||
fetchProducts();
|
fetchProducts();
|
||||||
}, [fetchProducts]);
|
}, [fetchProducts]);
|
||||||
|
|
||||||
const handleFilterChange = (key, value) => {
|
const handleFilterChange = <K extends keyof Filters>(key: K, value: Filters[K]): void => {
|
||||||
setFilters(prev => ({
|
setFilters(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
pageNumber: key !== 'pageNumber' ? 1 : value
|
pageNumber: key !== 'pageNumber' ? 1 : (value as number)
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = (): void => {
|
||||||
handleFilterChange('searchTerm', searchInput);
|
handleFilterChange('searchTerm', searchInput);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePriceRangeChange = (event, newValue) => {
|
const handlePriceRangeChange = (event: Event, newValue: number | number[]): void => {
|
||||||
setPriceRange(newValue);
|
setPriceRange(newValue as number[]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePriceRangeCommitted = (event, newValue) => {
|
const handlePriceRangeCommitted = (event: Event | React.SyntheticEvent, newValue: number | number[]): void => {
|
||||||
handleFilterChange('minPrice', newValue[0]);
|
const range = newValue as number[];
|
||||||
handleFilterChange('maxPrice', newValue[1]);
|
handleFilterChange('minPrice', range[0]);
|
||||||
|
handleFilterChange('maxPrice', range[1]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSortChange = (value) => {
|
const handleSortChange = (value: string): void => {
|
||||||
const [sortBy, sortDirection] = value.split('-');
|
const [sortBy, sortDirection] = value.split('-');
|
||||||
handleFilterChange('sortBy', sortBy);
|
handleFilterChange('sortBy', sortBy);
|
||||||
handleFilterChange('sortDirection', sortDirection);
|
handleFilterChange('sortDirection', sortDirection);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCategoryExpansion = (categoryId) => {
|
const toggleCategoryExpansion = (categoryId: string): void => {
|
||||||
const newExpanded = new Set(expandedCategories);
|
const newExpanded = new Set(expandedCategories);
|
||||||
if (newExpanded.has(categoryId)) {
|
if (newExpanded.has(categoryId)) {
|
||||||
newExpanded.delete(categoryId);
|
newExpanded.delete(categoryId);
|
||||||
@@ -175,15 +230,15 @@ export default function GalleryPage() {
|
|||||||
setExpandedCategories(newExpanded);
|
setExpandedCategories(newExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getChildCategories = (parentId) => {
|
const getChildCategories = (parentId: string): Category[] => {
|
||||||
return categories.filter(cat => cat.parentCategoryId === parentId);
|
return categories.filter(cat => cat.parentCategoryId === parentId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getParentCategories = () => {
|
const getParentCategories = (): Category[] => {
|
||||||
return categories.filter(cat => !cat.parentCategoryId);
|
return categories.filter(cat => !cat.parentCategoryId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CategorySidebar = () => (
|
const CategorySidebar = (): JSX.Element => (
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
width: 300,
|
width: 300,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -304,7 +359,9 @@ export default function GalleryPage() {
|
|||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={filters.isCustomizable === true}
|
checked={filters.isCustomizable === true}
|
||||||
onChange={(e) => handleFilterChange('isCustomizable', e.target.checked ? true : null)}
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
handleFilterChange('isCustomizable', e.target.checked ? true : null)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="Customizable Only"
|
label="Customizable Only"
|
||||||
@@ -376,7 +433,7 @@ export default function GalleryPage() {
|
|||||||
}}>
|
}}>
|
||||||
<Grid container spacing={{ xs: 1, sm: 2 }} alignItems="center">
|
<Grid container spacing={{ xs: 1, sm: 2 }} alignItems="center">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Grid item>
|
<Grid>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setMobileDrawerOpen(true)}
|
onClick={() => setMobileDrawerOpen(true)}
|
||||||
sx={{ mr: 1 }}
|
sx={{ mr: 1 }}
|
||||||
@@ -387,13 +444,13 @@ export default function GalleryPage() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Grid item xs={12} sm={6} md={4}>
|
<Grid size={{ xs:12, sm:6, md:4 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
placeholder="Search products..."
|
placeholder="Search products..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchInput(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyPress={(e: KeyboardEvent) => e.key === 'Enter' && handleSearch()}
|
||||||
size={isMobile ? "small" : "medium"}
|
size={isMobile ? "small" : "medium"}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
@@ -416,14 +473,14 @@ export default function GalleryPage() {
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={6} sm={3} md={3}>
|
<Grid size={{ xs:6, sm:3, md:3 }}>
|
||||||
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
|
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
|
||||||
<InputLabel>Sort By</InputLabel>
|
<InputLabel>Sort By</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={`${filters.sortBy}-${filters.sortDirection}`}
|
value={`${filters.sortBy}-${filters.sortDirection}`}
|
||||||
label="Sort By"
|
label="Sort By"
|
||||||
onChange={(e) => handleSortChange(e.target.value)}
|
onChange={(e: SelectChangeEvent) => handleSortChange(e.target.value)}
|
||||||
>
|
>
|
||||||
{SORT_OPTIONS.map((option) => (
|
{SORT_OPTIONS.map((option) => (
|
||||||
<MenuItem key={option.value} value={option.value}>
|
<MenuItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -433,13 +490,15 @@ export default function GalleryPage() {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={6} sm={3} md={2}>
|
<Grid size={{ xs:6, sm:3, md:2 }}>
|
||||||
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
|
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
|
||||||
<InputLabel>Per Page</InputLabel>
|
<InputLabel>Per Page</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={filters.pageSize}
|
value={filters.pageSize}
|
||||||
label="Per Page"
|
label="Per Page"
|
||||||
onChange={(e) => handleFilterChange('pageSize', e.target.value)}
|
onChange={(e: SelectChangeEvent<number>) =>
|
||||||
|
handleFilterChange('pageSize', e.target.value as number)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
<MenuItem key={size} value={size}>
|
<MenuItem key={size} value={size}>
|
||||||
@@ -450,7 +509,7 @@ export default function GalleryPage() {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} md={3} sx={{ textAlign: { xs: 'center', md: 'right' } }}>
|
<Grid size={{ xs:12, md:3 }} sx={{ textAlign: { xs: 'center', md: 'right' } }}>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{
|
<Typography variant="body2" color="text.secondary" sx={{
|
||||||
fontSize: { xs: '0.75rem', sm: '0.875rem' }
|
fontSize: { xs: '0.75rem', sm: '0.875rem' }
|
||||||
}}>
|
}}>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
import MuiThemeProvider from './components/theme/MuiThemeProvider';
|
import MuiThemeProvider from './components/theme/MuiThemeProvider';
|
||||||
import { ThemeContextProvider } from './components/theme/ThemeContext';
|
import { ThemeContextProvider } from './components/theme/ThemeContext';
|
||||||
import { AppRouterCacheProvider } from "@mui/material-nextjs/v13-appRouter";
|
import { AppRouterCacheProvider } from "@mui/material-nextjs/v13-appRouter";
|
||||||
@@ -7,12 +9,16 @@ import ClientLayoutEffect from "@/app/components/ClientLayoutEffect";
|
|||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Imprink',
|
title: 'Imprink',
|
||||||
description: 'Turn your dreams into colorful realities!',
|
description: 'Turn your dreams into colorful realities!',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({children}) {
|
interface RootLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
447
ui/src/app/page.tsx
Normal file
447
ui/src/app/page.tsx
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
'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.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 />}
|
||||||
|
>
|
||||||
|
Start Your Order Today
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
ui/src/app/token/route.ts
Normal file
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
36
ui/src/lib/clientApi.ts
Normal file
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;
|
||||||
12
ui/src/middleware.ts
Normal file
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).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
27
ui/tsconfig.json
Normal file
27
ui/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {}
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
|
|
||||||
3436
webui/package-lock.json
generated
3436
webui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "webui",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev --turbopack",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "next lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@auth0/nextjs-auth0": "^4.6.1",
|
|
||||||
"@emotion/cache": "^11.14.0",
|
|
||||||
"@emotion/react": "^11.14.0",
|
|
||||||
"@emotion/serialize": "^1.3.3",
|
|
||||||
"@emotion/styled": "^11.14.0",
|
|
||||||
"@mui/icons-material": "^7.1.1",
|
|
||||||
"@mui/material": "^7.1.1",
|
|
||||||
"@mui/material-nextjs": "^7.1.1",
|
|
||||||
"@mui/x-data-grid": "^8.5.2",
|
|
||||||
"@mui/x-date-pickers": "^8.5.2",
|
|
||||||
"@stripe/react-stripe-js": "^3.7.0",
|
|
||||||
"@stripe/stripe-js": "^7.3.1",
|
|
||||||
"axios": "^1.9.0",
|
|
||||||
"formik": "^2.4.6",
|
|
||||||
"i18next": "^25.2.1",
|
|
||||||
"lucide-react": "^0.516.0",
|
|
||||||
"next": "15.3.3",
|
|
||||||
"next-i18next": "^15.4.2",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"react-i18next": "^15.5.3",
|
|
||||||
"yup": "^1.6.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"tailwindcss": "^4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import {
|
|
||||||
AppBar,
|
|
||||||
Button,
|
|
||||||
Toolbar,
|
|
||||||
Typography,
|
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
IconButton,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
Divider,
|
|
||||||
useMediaQuery,
|
|
||||||
useTheme,
|
|
||||||
Paper
|
|
||||||
} from "@mui/material";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useUser } from "@auth0/nextjs-auth0";
|
|
||||||
import {
|
|
||||||
Menu as MenuIcon,
|
|
||||||
Home,
|
|
||||||
PhotoLibrary,
|
|
||||||
ShoppingBag,
|
|
||||||
Store,
|
|
||||||
Dashboard,
|
|
||||||
AdminPanelSettings,
|
|
||||||
Api,
|
|
||||||
BugReport
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
import ThemeToggleButton from "@/app/components/theme/ThemeToggleButton";
|
|
||||||
import useRoles from "@/app/components/hooks/useRoles";
|
|
||||||
|
|
||||||
export default function ImprinkAppBar() {
|
|
||||||
const { user, error, isLoading } = useUser();
|
|
||||||
const { isMerchant, isAdmin } = useRoles();
|
|
||||||
const [anchorEl, setAnchorEl] = useState(null);
|
|
||||||
const theme = useTheme();
|
|
||||||
const { isDarkMode, toggleTheme } = useTheme();
|
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
|
|
||||||
const handleMenuOpen = (event) => {
|
|
||||||
setAnchorEl(event.currentTarget);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMenuClose = () => {
|
|
||||||
setAnchorEl(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigationLinks = [
|
|
||||||
{ 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 = [
|
|
||||||
{ 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}
|
|
||||||
alt={user.name}
|
|
||||||
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 sx={{ mr: 2 }} />
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Box sx={{ ml: 2 }}>
|
|
||||||
<Typography variant="body2">Loading...</Typography>
|
|
||||||
</Box>
|
|
||||||
) : user ? (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: 2 }}>
|
|
||||||
<Avatar
|
|
||||||
src={user.picture}
|
|
||||||
alt={user.name}
|
|
||||||
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()}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
const ThemeContext = createContext({ isDarkMode: true });
|
|
||||||
|
|
||||||
function getInitialTheme() {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedTheme = localStorage.getItem('theme-preference');
|
|
||||||
if (savedTheme) {
|
|
||||||
return savedTheme === 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeContextProvider({ children }) {
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initialTheme = getInitialTheme();
|
|
||||||
if (initialTheme !== null) {
|
|
||||||
setIsDarkMode(initialTheme);
|
|
||||||
}
|
|
||||||
setIsInitialized(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
const newTheme = !isDarkMode;
|
|
||||||
setIsDarkMode(newTheme);
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('theme-preference', newTheme ? 'dark' : 'light');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isInitialized) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
visibility: 'hidden',
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%'
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ isDarkMode, toggleTheme }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme() {
|
|
||||||
const context = useContext(ThemeContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useTheme must be used within a ThemeContextProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
Typography,
|
|
||||||
Grid,
|
|
||||||
MenuItem,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
FormHelperText,
|
|
||||||
Chip,
|
|
||||||
Avatar,
|
|
||||||
Container,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { useFormik } from 'formik';
|
|
||||||
import * as yup from 'yup';
|
|
||||||
import PersonIcon from '@mui/icons-material/Person';
|
|
||||||
import EmailIcon from '@mui/icons-material/Email';
|
|
||||||
import PhoneIcon from '@mui/icons-material/Phone';
|
|
||||||
import WorkIcon from '@mui/icons-material/Work';
|
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
|
||||||
|
|
||||||
const validationSchema = yup.object({
|
|
||||||
firstName: yup
|
|
||||||
.string('Enter your first name')
|
|
||||||
.min(2, 'First name should be at least 2 characters')
|
|
||||||
.required('First name is required'),
|
|
||||||
lastName: yup
|
|
||||||
.string('Enter your last name')
|
|
||||||
.min(2, 'Last name should be at least 2 characters')
|
|
||||||
.required('Last name is required'),
|
|
||||||
email: yup
|
|
||||||
.string('Enter your email')
|
|
||||||
.email('Enter a valid email')
|
|
||||||
.required('Email is required'),
|
|
||||||
phone: yup
|
|
||||||
.string('Enter your phone number')
|
|
||||||
.matches(/^[\+]?[1-9][\d]{0,15}$/, 'Enter a valid phone number')
|
|
||||||
.required('Phone number is required'),
|
|
||||||
age: yup
|
|
||||||
.number('Enter your age')
|
|
||||||
.min(18, 'Must be at least 18 years old')
|
|
||||||
.max(120, 'Must be less than 120 years old')
|
|
||||||
.required('Age is required'),
|
|
||||||
department: yup
|
|
||||||
.string('Select a department')
|
|
||||||
.required('Department is required'),
|
|
||||||
skills: yup
|
|
||||||
.array()
|
|
||||||
.min(1, 'Select at least one skill')
|
|
||||||
.required('Skills are required'),
|
|
||||||
bio: yup
|
|
||||||
.string('Enter your bio')
|
|
||||||
.min(10, 'Bio should be at least 10 characters')
|
|
||||||
.max(500, 'Bio should not exceed 500 characters')
|
|
||||||
.required('Bio is required'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const departments = [
|
|
||||||
'Engineering',
|
|
||||||
'Marketing',
|
|
||||||
'Sales',
|
|
||||||
'HR',
|
|
||||||
'Finance',
|
|
||||||
'Operations',
|
|
||||||
'Design',
|
|
||||||
];
|
|
||||||
|
|
||||||
const skillOptions = [
|
|
||||||
'JavaScript',
|
|
||||||
'React',
|
|
||||||
'Node.js',
|
|
||||||
'Python',
|
|
||||||
'UI/UX Design',
|
|
||||||
'Project Management',
|
|
||||||
'Data Analysis',
|
|
||||||
'Marketing',
|
|
||||||
'Sales',
|
|
||||||
'Communication',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function FormPage() {
|
|
||||||
const formik = useFormik({
|
|
||||||
initialValues: {
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
age: '',
|
|
||||||
department: '',
|
|
||||||
skills: [],
|
|
||||||
bio: '',
|
|
||||||
},
|
|
||||||
validationSchema: validationSchema,
|
|
||||||
onSubmit: (values, { setSubmitting, resetForm }) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('Form submitted:', values);
|
|
||||||
alert('Form submitted successfully!');
|
|
||||||
setSubmitting(false);
|
|
||||||
resetForm();
|
|
||||||
}, 1000);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSkillChange = (event) => {
|
|
||||||
const value = event.target.value;
|
|
||||||
formik.setFieldValue('skills', typeof value === 'string' ? value.split(',') : value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
minHeight: '100vh',
|
|
||||||
background: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? 'linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%)'
|
|
||||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
||||||
py: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Container maxWidth="sm">
|
|
||||||
<Card
|
|
||||||
elevation={8}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 3,
|
|
||||||
background: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? 'rgba(26, 26, 46, 0.95)'
|
|
||||||
: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
border: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? '1px solid rgba(99, 102, 241, 0.2)'
|
|
||||||
: '1px solid rgba(255, 255, 255, 0.3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardContent sx={{ p: 4 }}>
|
|
||||||
<Box
|
|
||||||
display="flex"
|
|
||||||
flexDirection="column"
|
|
||||||
alignItems="center"
|
|
||||||
mb={4}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'primary.main',
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PersonIcon fontSize="large" />
|
|
||||||
</Avatar>
|
|
||||||
<Typography
|
|
||||||
variant="h4"
|
|
||||||
component="h1"
|
|
||||||
fontWeight="bold"
|
|
||||||
color="primary"
|
|
||||||
textAlign="center"
|
|
||||||
>
|
|
||||||
User Registration
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="subtitle1" color="text.secondary" textAlign="center">
|
|
||||||
Please fill out all required fields
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<form onSubmit={formik.handleSubmit}>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
<Typography variant="h6" color="primary" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<PersonIcon /> Personal Information
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid item xs={6}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="firstName"
|
|
||||||
name="firstName"
|
|
||||||
label="First Name"
|
|
||||||
value={formik.values.firstName}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.firstName && Boolean(formik.errors.firstName)}
|
|
||||||
helperText={formik.touched.firstName && formik.errors.firstName}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="lastName"
|
|
||||||
name="lastName"
|
|
||||||
label="Last Name"
|
|
||||||
value={formik.values.lastName}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.lastName && Boolean(formik.errors.lastName)}
|
|
||||||
helperText={formik.touched.lastName && formik.errors.lastName}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
label="Email Address"
|
|
||||||
type="email"
|
|
||||||
value={formik.values.email}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.email && Boolean(formik.errors.email)}
|
|
||||||
helperText={formik.touched.email && formik.errors.email}
|
|
||||||
variant="outlined"
|
|
||||||
InputProps={{
|
|
||||||
startAdornment: <EmailIcon sx={{ color: 'action.active', mr: 1 }} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="phone"
|
|
||||||
name="phone"
|
|
||||||
label="Phone Number"
|
|
||||||
value={formik.values.phone}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.phone && Boolean(formik.errors.phone)}
|
|
||||||
helperText={formik.touched.phone && formik.errors.phone}
|
|
||||||
variant="outlined"
|
|
||||||
InputProps={{
|
|
||||||
startAdornment: <PhoneIcon sx={{ color: 'action.active', mr: 1 }} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="age"
|
|
||||||
name="age"
|
|
||||||
label="Age"
|
|
||||||
type="number"
|
|
||||||
value={formik.values.age}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.age && Boolean(formik.errors.age)}
|
|
||||||
helperText={formik.touched.age && formik.errors.age}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="h6" color="primary" sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
|
||||||
<WorkIcon /> Professional Information
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<FormControl
|
|
||||||
fullWidth
|
|
||||||
error={formik.touched.department && Boolean(formik.errors.department)}
|
|
||||||
>
|
|
||||||
<InputLabel id="department-label">Department</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="department-label"
|
|
||||||
id="department"
|
|
||||||
name="department"
|
|
||||||
value={formik.values.department}
|
|
||||||
label="Department"
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
>
|
|
||||||
{departments.map((dept) => (
|
|
||||||
<MenuItem key={dept} value={dept}>
|
|
||||||
{dept}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{formik.touched.department && formik.errors.department && (
|
|
||||||
<FormHelperText>{formik.errors.department}</FormHelperText>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl
|
|
||||||
fullWidth
|
|
||||||
error={formik.touched.skills && Boolean(formik.errors.skills)}
|
|
||||||
>
|
|
||||||
<InputLabel id="skills-label">Skills</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="skills-label"
|
|
||||||
id="skills"
|
|
||||||
multiple
|
|
||||||
value={formik.values.skills}
|
|
||||||
onChange={handleSkillChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
label="Skills"
|
|
||||||
renderValue={(selected) => (
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{selected.map((value) => (
|
|
||||||
<Chip key={value} label={value} size="small" />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{skillOptions.map((skill) => (
|
|
||||||
<MenuItem key={skill} value={skill}>
|
|
||||||
{skill}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{formik.touched.skills && formik.errors.skills && (
|
|
||||||
<FormHelperText>{formik.errors.skills}</FormHelperText>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
id="bio"
|
|
||||||
name="bio"
|
|
||||||
label="Bio"
|
|
||||||
multiline
|
|
||||||
rows={4}
|
|
||||||
value={formik.values.bio}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
error={formik.touched.bio && Boolean(formik.errors.bio)}
|
|
||||||
helperText={
|
|
||||||
formik.touched.bio && formik.errors.bio
|
|
||||||
? formik.errors.bio
|
|
||||||
: `${formik.values.bio.length}/500 characters`
|
|
||||||
}
|
|
||||||
variant="outlined"
|
|
||||||
placeholder="Tell us about yourself..."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
variant="contained"
|
|
||||||
fullWidth
|
|
||||||
size="large"
|
|
||||||
type="submit"
|
|
||||||
disabled={formik.isSubmitting}
|
|
||||||
startIcon={<SendIcon />}
|
|
||||||
sx={{
|
|
||||||
mt: 2,
|
|
||||||
py: 1.5,
|
|
||||||
borderRadius: 3,
|
|
||||||
background: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? 'linear-gradient(45deg, #6366f1 30%, #8b5cf6 90%)'
|
|
||||||
: 'linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)',
|
|
||||||
boxShadow: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? '0 3px 5px 2px rgba(99, 102, 241, .3)'
|
|
||||||
: '0 3px 5px 2px rgba(255, 105, 135, .3)',
|
|
||||||
'&:hover': {
|
|
||||||
background: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? 'linear-gradient(45deg, #5b21b6 60%, #7c3aed 100%)'
|
|
||||||
: 'linear-gradient(45deg, #FE6B8B 60%, #FF8E53 100%)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formik.isSubmitting ? 'Submitting...' : 'Submit Registration'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
|
|
||||||
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
|
||||||
'Helvetica Neue', sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
color: #333;
|
|
||||||
line-height: 1.6;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header h1 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header p {
|
|
||||||
color: #666;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.products h2 {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-card {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-card h3 {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #0570de;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-btn {
|
|
||||||
background: #0570de;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-btn:hover:not(:disabled) {
|
|
||||||
background: #0458b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-btn:disabled {
|
|
||||||
background: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkout {
|
|
||||||
max-width: 500px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-summary {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-summary h2 {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-details p {
|
|
||||||
margin: 8px 0;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn:hover {
|
|
||||||
background: #5a6268;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Payment Form Styles */
|
|
||||||
.payment-form {
|
|
||||||
max-width: 500px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-section {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-section h3 {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
color: #333;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pay-button {
|
|
||||||
width: 100%;
|
|
||||||
background: #0570de;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pay-button:hover:not(:disabled) {
|
|
||||||
background: #0458b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pay-button:disabled {
|
|
||||||
background: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-border {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-top: 2px solid white;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
margin-top: 15px;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-container {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-message {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
padding: 30px;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-message h2 {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-message p {
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Container,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardMedia,
|
|
||||||
Grid,
|
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
|
||||||
Alert
|
|
||||||
} from '@mui/material';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { ShoppingCart, Palette, ImageOutlined, CreditCard, LocalShipping, CheckCircle } from '@mui/icons-material';
|
|
||||||
import clientApi from "@/lib/clientApi";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
const [products, setProducts] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchProducts = async () => {
|
|
||||||
try {
|
|
||||||
const response = await clientApi.get('/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 = [
|
|
||||||
{
|
|
||||||
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.'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
|
||||||
<Grid container spacing={6} alignItems="center" sx={{ mb: 6, minHeight: '70vh' }}>
|
|
||||||
<Grid item 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>
|
|
||||||
|
|
||||||
<Box sx={{ mb: 4 }}>
|
|
||||||
<Grid container spacing={3}>
|
|
||||||
<Grid item 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 item 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 item 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 item 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) => (
|
|
||||||
<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, index) => (
|
|
||||||
<Grid item 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 item 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 item 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 />}
|
|
||||||
>
|
|
||||||
Start Your Order Today
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import {auth0} from "@/lib/auth0";
|
|
||||||
import serverApi from "@/lib/serverApi";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const token = (await auth0.getSession()).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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
|
|
||||||
const clientApi = axios.create({
|
|
||||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
clientApi.interceptors.request.use(async (config) => {
|
|
||||||
if (typeof window === 'undefined') return config;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/auth/access-token');
|
|
||||||
if (!res.ok)
|
|
||||||
throw new Error('Failed to fetch token');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.token) {
|
|
||||||
config.headers.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 => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default clientApi;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { auth0 } from "./lib/auth0";
|
|
||||||
|
|
||||||
export async function middleware(request) {
|
|
||||||
return await auth0.middleware(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user