mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
hot fix #1
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
REACT_APP_API_URL=/api/v1/eshop
|
||||
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_TYooMQauvdEDq54NiTphI7jx
|
||||
@@ -0,0 +1,2 @@
|
||||
REACT_APP_API_URL=/api/v1/eshop
|
||||
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
@@ -0,0 +1,45 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm i -g npm@10 \
|
||||
&& npm ci --prefer-offline --no-audit --no-fund || npm install --no-audit --no-fund
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build app
|
||||
ENV NODE_ENV=production
|
||||
ENV GENERATE_SOURCEMAP=false
|
||||
ENV CI=true
|
||||
ENV TSC_COMPILE_ON_ERROR=true
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default nginx static assets
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Ensure nginx cache directory is writable for non-root user
|
||||
RUN mkdir -p /var/cache/nginx/client_temp \
|
||||
&& chown -R nginx:nginx /var/cache/nginx
|
||||
|
||||
# Switch to non-root user
|
||||
USER nginx
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"files": {
|
||||
"main.js": "/static/js/main.590e0c36.js",
|
||||
"index.html": "/index.html",
|
||||
"main.590e0c36.js.map": "/static/js/main.590e0c36.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/js/main.590e0c36.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="cs"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#1a365d"/><title>MyClub E-shop</title><script defer="defer" src="/static/js/main.590e0c36.js"></script></head><body><noscript>Pro zobrazení e-shopu je potřeba povolit JavaScript.</noscript><div id="root"></div></body></html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* use-sync-external-store-shim.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @remix-run/router v1.23.1
|
||||
*
|
||||
* Copyright (c) Remix Software Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* React Router DOM v6.30.2
|
||||
*
|
||||
* Copyright (c) Remix Software Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* React Router v6.30.2
|
||||
*
|
||||
* Copyright (c) Remix Software Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/** @license React v16.13.1
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Static assets & SPA routing
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to eshop-backend
|
||||
location /api/ {
|
||||
proxy_pass http://eshop-backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 20m;
|
||||
}
|
||||
}
|
||||
Generated
+16688
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "myclub-eshop-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.5.3",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-router-dom": "^5.3.3"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#1a365d" />
|
||||
<title>MyClub E-shop</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>Pro zobrazení e-shopu je potřeba povolit JavaScript.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Link as RouterLink } from 'react-router-dom';
|
||||
import { Box, Container, Flex, HStack, Heading, Link, Spacer, Button } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getClubInfo, ClubInfo } from './services/eshopApi';
|
||||
import ShopHomePage from './pages/ShopHomePage';
|
||||
import ProductDetailPage from './pages/ProductDetailPage';
|
||||
import CartPage from './pages/CartPage';
|
||||
import CheckoutPage from './pages/CheckoutPage';
|
||||
import AdminDashboardPage from './pages/AdminDashboardPage';
|
||||
import SetupPage from './pages/SetupPage';
|
||||
import OrderSuccessPage from './pages/OrderSuccessPage';
|
||||
import SupportChatWidget from './components/SupportChatWidget';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { data: clubInfo } = useQuery<ClubInfo>(['eshop-club-info'], getClubInfo);
|
||||
const shopName = clubInfo?.club_name ? `${clubInfo.club_name} E-shop` : 'MyClub E-shop';
|
||||
const headerBg = clubInfo?.primary_color || 'gray.900';
|
||||
|
||||
return (
|
||||
<Flex direction="column" minH="100vh">
|
||||
<Box as="header" borderBottomWidth="1px" py={3} bg={headerBg} color="white">
|
||||
<Container maxW="6xl">
|
||||
<HStack spacing={4} align="center">
|
||||
<Heading size="md">
|
||||
<Link as={RouterLink} to="/" _hover={{ textDecoration: 'none', opacity: 0.9 }}>
|
||||
{shopName}
|
||||
</Link>
|
||||
</Heading>
|
||||
<HStack as="nav" spacing={4} fontSize="sm">
|
||||
<Link as={RouterLink} to="/" _hover={{ opacity: 0.85 }}>Produkty</Link>
|
||||
<Link as={RouterLink} to="/cart" _hover={{ opacity: 0.85 }}>Košík</Link>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<Button as={RouterLink} to="/admin" size="sm" variant="outline" colorScheme="whiteAlpha">
|
||||
E-shop admin
|
||||
</Button>
|
||||
</HStack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<Box as="main" flex="1" py={6} bg="gray.50">
|
||||
<Container maxW="6xl">
|
||||
<Routes>
|
||||
<Route path="/" element={<ShopHomePage />} />
|
||||
<Route path="/produkt/:slug" element={<ProductDetailPage />} />
|
||||
<Route path="/cart" element={<CartPage />} />
|
||||
<Route path="/checkout" element={<CheckoutPage />} />
|
||||
<Route path="/objednavka/dekujeme" element={<OrderSuccessPage />} />
|
||||
<Route path="/admin" element={<AdminDashboardPage />} />
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
</Routes>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<SupportChatWidget />
|
||||
|
||||
<Box as="footer" borderTopWidth="1px" py={4} bg="white" fontSize="sm" color="gray.500">
|
||||
<Container maxW="6xl">
|
||||
© {new Date().getFullYear()} MyClub E-shop.
|
||||
</Container>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,272 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Input,
|
||||
Text,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
Flex,
|
||||
Spinner,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaComments, FaPaperPlane, FaTimes, FaRobot, FaUser } from 'react-icons/fa';
|
||||
import { getOrCreateEshopSessionToken } from '../services/eshopApi';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
const RobotIcon: React.ComponentType<any> = FaRobot as any;
|
||||
const TimesIcon: React.ComponentType<any> = FaTimes as any;
|
||||
const PaperPlaneIcon: React.ComponentType<any> = FaPaperPlane as any;
|
||||
const CommentsIcon: React.ComponentType<any> = FaComments as any;
|
||||
|
||||
const SupportChatWidget: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const chatBg = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isOpen]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMessage = input.trim();
|
||||
setInput('');
|
||||
setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/eshop/support/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Session-Token': getOrCreateEshopSessionToken(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: userMessage,
|
||||
history: messages.filter(m => m.role !== 'system').slice(-10),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
if (!response.body) return;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let assistantMessage = '';
|
||||
|
||||
// Add placeholder for assistant message
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
|
||||
try {
|
||||
// DeepSeek streaming format: {"id":..., "choices":[{"delta":{"content":"..."}}]}
|
||||
// BUT our backend just forwards raw content chunks or JSON?
|
||||
// Let's check backend implementation: it forwards JSON chunks from DeepSeek
|
||||
const json = JSON.parse(data);
|
||||
const content = json.choices?.[0]?.delta?.content || '';
|
||||
if (content) {
|
||||
assistantMessage += content;
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev];
|
||||
newMessages[newMessages.length - 1].content = assistantMessage;
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// If backend sends raw text or error
|
||||
console.error('Error parsing stream chunk', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Chat error:', error);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Omlouvám se, ale došlo k chybě při komunikaci s asistentem. Zkuste to prosím později nebo napište na klubový e‑mail.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box position="fixed" bottom="20px" right="20px" zIndex={1000}>
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<Box
|
||||
bg={bg}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="xl"
|
||||
w="350px"
|
||||
h="500px"
|
||||
mb={4}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<Flex
|
||||
bg="blue.600"
|
||||
color="white"
|
||||
p={3}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<HStack>
|
||||
<RobotIcon />
|
||||
<Text fontWeight="bold">MyClub Asistent</Text>
|
||||
</HStack>
|
||||
<IconButton
|
||||
aria-label="Close chat"
|
||||
icon={<TimesIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="whiteAlpha"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* Messages */}
|
||||
<VStack
|
||||
flex={1}
|
||||
overflowY="auto"
|
||||
p={3}
|
||||
spacing={3}
|
||||
align="stretch"
|
||||
bg={chatBg}
|
||||
>
|
||||
{messages.length === 0 && (
|
||||
<Box textAlign="center" color="gray.500" mt={10}>
|
||||
<Box as={RobotIcon} fontSize="40px" mb={2} />
|
||||
<Text fontSize="sm">
|
||||
Ahoj! Jsem virtuální asistent MyClub e‑shopu. <br />
|
||||
Mohu poradit s produkty, dostupností, dopravou nebo stavem objednávky.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{messages.map((msg, idx) => {
|
||||
const isUser = msg.role === 'user';
|
||||
const isSystem = msg.role === 'system';
|
||||
const bubbleBg = isUser ? 'blue.500' : isSystem ? 'red.500' : 'white';
|
||||
const bubbleColor = isUser || isSystem ? 'white' : 'black';
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={idx}
|
||||
justify={isUser ? 'flex-end' : 'flex-start'}
|
||||
>
|
||||
<Box
|
||||
maxW="80%"
|
||||
bg={bubbleBg}
|
||||
color={bubbleColor}
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderTopRightRadius={isUser ? 0 : 'lg'}
|
||||
borderTopLeftRadius={!isUser ? 0 : 'lg'}
|
||||
>
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap">
|
||||
{msg.content}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
{isLoading && (
|
||||
<Flex justify="flex-start">
|
||||
<Box bg="white" px={3} py={2} borderRadius="lg" boxShadow="sm">
|
||||
<Spinner size="xs" color="gray.500" />
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</VStack>
|
||||
|
||||
{/* Input */}
|
||||
<Box p={3} borderTop="1px solid" borderColor={borderColor}>
|
||||
<HStack>
|
||||
<Input
|
||||
placeholder="Napište zprávu..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Send"
|
||||
icon={<PaperPlaneIcon />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
borderRadius="full"
|
||||
onClick={handleSend}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{!isOpen && (
|
||||
<Button
|
||||
leftIcon={<CommentsIcon />}
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
boxShadow="lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Podpora
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportChatWidget;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import { theme } from './theme';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ChakraProvider theme={theme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ChakraProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Box, Heading, Text, Alert, AlertIcon, AlertTitle, AlertDescription } from '@chakra-ui/react';
|
||||
|
||||
const AdminDashboardPage: React.FC = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>E-shop administrace</Heading>
|
||||
<Alert status="info" borderRadius="md" mb={4}>
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<AlertTitle>Samostatná administrace e-shopu</AlertTitle>
|
||||
<AlertDescription fontSize="sm">
|
||||
Toto je základní rozhraní pro budoucí správu produktů, objednávek a dopravy.
|
||||
V této fázi doporučujeme produkty zakládat přes hlavní MyClub administraci nebo přímo v databázi.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
<Text color="gray.600">
|
||||
V dalších krocích zde přibudou přehledy objednávek, správa produktů, nastavení Stripe a Packeta integrace
|
||||
a nástroje pro zákaznickou podporu (DeepSeek AI chat).
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboardPage;
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getCart, updateCartItem, removeCartItem, EshopCart } from '../services/eshopApi';
|
||||
import { Box, Heading, Text, Table, Thead, Tbody, Tr, Th, Td, IconButton, HStack, NumberInput, NumberInputField, Button, useToast } from '@chakra-ui/react';
|
||||
import { FiTrash2 } from 'react-icons/fi';
|
||||
|
||||
const TrashIcon: React.ComponentType<any> = FiTrash2 as any;
|
||||
|
||||
const formatPrice = (cents: number, currency: string) => {
|
||||
const value = cents / 100;
|
||||
return new Intl.NumberFormat('cs-CZ', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const CartPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError } = useQuery<EshopCart>(['eshop-cart'], getCart);
|
||||
|
||||
const handleQuantityChange = async (id: number, value: number) => {
|
||||
try {
|
||||
await updateCartItem(id, value);
|
||||
queryClient.invalidateQueries(['eshop-cart']);
|
||||
} catch {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se upravit košík.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (id: number) => {
|
||||
try {
|
||||
await removeCartItem(id);
|
||||
queryClient.invalidateQueries(['eshop-cart']);
|
||||
} catch {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se odebrat položku.' });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Text>Načítání košíku…</Text>;
|
||||
if (isError || !data) return <Text>Košík nelze načíst.</Text>;
|
||||
|
||||
const total = data.items?.reduce((sum, it) => sum + it.unit_price_cents * it.quantity, 0) || 0;
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>Košík je prázdný</Heading>
|
||||
<Text color="gray.600">Přidejte produkty z katalogu a vraťte se sem.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>Košík</Heading>
|
||||
<Table size="sm" variant="simple" mb={4}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Produkt</Th>
|
||||
<Th isNumeric>Množství</Th>
|
||||
<Th isNumeric>Cena</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{data.items.map((it) => (
|
||||
<Tr key={it.id}>
|
||||
<Td>
|
||||
<Text fontWeight="medium">{it.product?.name || `Produkt #${it.product_id}`}</Text>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<HStack justify="flex-end">
|
||||
<NumberInput
|
||||
size="sm"
|
||||
min={0}
|
||||
value={it.quantity}
|
||||
onChange={(_, val) => handleQuantityChange(it.id, val)}
|
||||
maxW="90px"
|
||||
>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td isNumeric>{formatPrice(it.unit_price_cents * it.quantity, it.currency || data.currency)}</Td>
|
||||
<Td isNumeric>
|
||||
<IconButton
|
||||
aria-label="Odebrat"
|
||||
icon={<TrashIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemove(it.id)}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text fontWeight="bold">Celkem:</Text>
|
||||
<Text fontWeight="bold">{formatPrice(total, data.currency || 'CZK')}</Text>
|
||||
</HStack>
|
||||
<Button colorScheme="blue" onClick={() => navigate('/checkout')}>
|
||||
Pokračovat k pokladně
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartPage;
|
||||
@@ -0,0 +1,475 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getCart, getPacketaWidgetConfig, checkout, EshopCart } from '../services/eshopApi';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Divider,
|
||||
HStack,
|
||||
useToast,
|
||||
Card,
|
||||
CardBody,
|
||||
Container,
|
||||
Spinner,
|
||||
FormErrorMessage,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Packeta: any;
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (cents: number, currency: string) => {
|
||||
const value = cents / 100;
|
||||
return new Intl.NumberFormat('cs-CZ', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// Stripe Payment Form Component
|
||||
const StripePaymentForm: React.FC<{
|
||||
clientSecret: string;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ clientSecret, onSuccess, onCancel }) => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/objednavka/dekujeme`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message || 'Došlo k chybě při zpracování platby.');
|
||||
} else {
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack spacing={4} as="form" onSubmit={handleSubmit}>
|
||||
<PaymentElement />
|
||||
|
||||
{errorMessage && (
|
||||
<Text color="red.500" fontSize="sm">{errorMessage}</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={4} width="100%">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
isDisabled={isLoading}
|
||||
flex={1}
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
isDisabled={!stripe || !elements}
|
||||
flex={1}
|
||||
>
|
||||
Zaplatit
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const CheckoutPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { data: cart, isLoading: cartLoading } = useQuery<EshopCart>(['eshop-cart'], getCart);
|
||||
const { data: packetaConfig, isLoading: packetaLoading, isError: packetaError } = useQuery(
|
||||
['packeta-config'],
|
||||
getPacketaWidgetConfig,
|
||||
);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [shippingMethod, setShippingMethod] = useState('packeta');
|
||||
const [selectedPoint, setSelectedPoint] = useState<any>(null);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showValidationErrors, setShowValidationErrors] = useState(false);
|
||||
|
||||
// Stripe states
|
||||
const [showStripeModal, setShowStripeModal] = useState(false);
|
||||
const [stripeClientSecret, setStripeClientSecret] = useState('');
|
||||
const [stripe, setStripe] = useState<any>(null);
|
||||
const [elements, setElements] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Load Packeta widget script
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://widget.packeta.com/v6/www/js/library.js';
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize Stripe when needed
|
||||
useEffect(() => {
|
||||
if (showStripeModal && stripeClientSecret) {
|
||||
const initStripe = async () => {
|
||||
const stripeKey = process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!stripeKey) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Stripe není nakonfigurován' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stripeInstance = await loadStripe(stripeKey);
|
||||
if (!stripeInstance) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se načíst Stripe' });
|
||||
return;
|
||||
}
|
||||
|
||||
setStripe(stripeInstance);
|
||||
// Elements will be created in the modal with the client secret
|
||||
};
|
||||
|
||||
initStripe();
|
||||
}
|
||||
}, [showStripeModal, stripeClientSecret, toast]);
|
||||
|
||||
const openPacketaWidget = () => {
|
||||
if (!window.Packeta || !packetaConfig) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Widget Zásilkovny není připraven.' });
|
||||
return;
|
||||
}
|
||||
|
||||
window.Packeta.Widget.pick(packetaConfig.api_key, (point: any) => {
|
||||
if (point) {
|
||||
setSelectedPoint(point);
|
||||
}
|
||||
}, {
|
||||
country: 'cz,sk',
|
||||
language: 'cs',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setShowValidationErrors(true);
|
||||
|
||||
// Basic validation
|
||||
if (!email || !firstName || !lastName || !phone) {
|
||||
toast({
|
||||
status: 'warning',
|
||||
title: 'Chybějící údaje',
|
||||
description: 'Vyplňte prosím všechny kontaktní údaje.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (shippingMethod === 'packeta' && !selectedPoint) {
|
||||
toast({ status: 'warning', title: 'Doprava', description: 'Vyberte prosím výdejní místo.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const billingAddress = {
|
||||
firstName, lastName, email, phone
|
||||
};
|
||||
|
||||
const shippingAddress = shippingMethod === 'packeta'
|
||||
? selectedPoint
|
||||
: billingAddress; // Fallback
|
||||
|
||||
const res = await checkout({
|
||||
email,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
phone,
|
||||
billing_address: billingAddress,
|
||||
shipping_address: shippingAddress,
|
||||
shipping_method: shippingMethod,
|
||||
});
|
||||
|
||||
// Manual email fallback (no online gateway)
|
||||
if (res.manual_payment && res.contact_email) {
|
||||
toast({
|
||||
status: 'info',
|
||||
title: 'Dokončení objednávky',
|
||||
description: `Online platba není aktuálně dostupná. Objednávku prosím pošlete na tento e-mail: ${res.contact_email}`,
|
||||
duration: 15000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// I bez online platby chceme zákazníkovi ukázat shrnutí objednávky
|
||||
if (res.order_id) {
|
||||
navigate(`/objednavka/dekujeme?order_id=${res.order_id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Online payment via redirect (Revolut)
|
||||
if (res.payment_redirect_url) {
|
||||
window.location.href = res.payment_redirect_url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Stripe payment - show payment modal
|
||||
if (res.client_secret) {
|
||||
setStripeClientSecret(res.client_secret);
|
||||
setShowStripeModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
status: 'error',
|
||||
title: 'Platba',
|
||||
description: 'Platební brána není aktuálně dostupná. Zkuste to prosím později nebo kontaktujte podporu.',
|
||||
});
|
||||
} catch (err) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se vytvořit objednávku.' });
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (cartLoading) return <Spinner />;
|
||||
if (!cart || !cart.items || cart.items.length === 0) {
|
||||
return (
|
||||
<Container maxW="3xl" py={10}>
|
||||
<Heading size="lg" mb={4}>Košík je prázdný</Heading>
|
||||
<Button onClick={() => navigate('/')}>Zpět do obchodu</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const itemsTotal = cart.items.reduce((sum, it) => sum + it.unit_price_cents * it.quantity, 0);
|
||||
const shippingPriceCents = shippingMethod === 'packeta' ? 7900 : 0; // match backend logic
|
||||
const grandTotal = itemsTotal + shippingPriceCents;
|
||||
|
||||
return (
|
||||
<Container maxW="3xl" py={8}>
|
||||
<Heading mb={6}>Pokladna</Heading>
|
||||
|
||||
<VStack spacing={8} align="stretch">
|
||||
{/* Contact Info */}
|
||||
<Card variant="outline">
|
||||
<CardBody>
|
||||
<Heading size="md" mb={4}>1. Kontaktní údaje</Heading>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired isInvalid={showValidationErrors && !email}>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<FormErrorMessage>Zadejte prosím e-mail.</FormErrorMessage>
|
||||
</FormControl>
|
||||
<HStack width="100%">
|
||||
<FormControl isRequired isInvalid={showValidationErrors && !firstName}>
|
||||
<FormLabel>Jméno</FormLabel>
|
||||
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||
<FormErrorMessage>Zadejte prosím jméno.</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isRequired isInvalid={showValidationErrors && !lastName}>
|
||||
<FormLabel>Příjmení</FormLabel>
|
||||
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||
<FormErrorMessage>Zadejte prosím příjmení.</FormErrorMessage>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<FormControl isRequired isInvalid={showValidationErrors && !phone}>
|
||||
<FormLabel>Telefon</FormLabel>
|
||||
<Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
<FormErrorMessage>Zadejte prosím telefon.</FormErrorMessage>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Shipping Method */}
|
||||
<Card variant="outline">
|
||||
<CardBody>
|
||||
<Heading size="md" mb={4}>2. Doprava</Heading>
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={showValidationErrors && shippingMethod === 'packeta' && !selectedPoint}
|
||||
>
|
||||
<RadioGroup value={shippingMethod} onChange={setShippingMethod}>
|
||||
<Stack direction="column">
|
||||
<Radio value="packeta">Zásilkovna (výdejní místa)</Radio>
|
||||
{/* Future: Add other carriers */}
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
|
||||
{shippingMethod === 'packeta' && (
|
||||
<Box mt={4} p={4} borderWidth="1px" borderRadius="md" bg="gray.50">
|
||||
{packetaLoading && (
|
||||
<Text fontSize="sm" color="gray.600" mb={2}>
|
||||
Načítání widgetu Zásilkovny...
|
||||
</Text>
|
||||
)}
|
||||
{packetaError && (
|
||||
<Text fontSize="sm" color="red.500" mb={2}>
|
||||
Nepodařilo se načíst konfiguraci Zásilkovny. Zkuste to prosím později.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{selectedPoint ? (
|
||||
<VStack align="start">
|
||||
<Text fontWeight="bold">Vybrané místo:</Text>
|
||||
<Text>{selectedPoint.name}</Text>
|
||||
<Text fontSize="sm" color="gray.600">{selectedPoint.street}, {selectedPoint.city}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={openPacketaWidget}
|
||||
variant="outline"
|
||||
mt={2}
|
||||
isDisabled={!!packetaError}
|
||||
>
|
||||
Změnit místo
|
||||
</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<Button
|
||||
onClick={openPacketaWidget}
|
||||
colorScheme="red"
|
||||
isDisabled={packetaLoading || !!packetaError}
|
||||
>
|
||||
Vybrat výdejní místo
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<FormErrorMessage>Vyberte prosím výdejní místo.</FormErrorMessage>
|
||||
</FormControl>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card variant="outline">
|
||||
<CardBody>
|
||||
<Heading size="md" mb={4}>3. Shrnutí objednávky</Heading>
|
||||
<VStack align="stretch" spacing={2} mb={4}>
|
||||
{cart.items.map((item) => (
|
||||
<HStack key={item.id} justify="space-between">
|
||||
<Text>{item.product?.name || 'Produkt'} x {item.quantity}</Text>
|
||||
<Text>
|
||||
{formatPrice(item.unit_price_cents * item.quantity, item.currency || cart.currency)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
<Divider my={2} />
|
||||
<HStack justify="space-between" fontWeight="bold">
|
||||
<Text>Celkem zboží</Text>
|
||||
<Text>{formatPrice(itemsTotal, cart.currency)}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text>Doprava</Text>
|
||||
<Text>
|
||||
{shippingPriceCents > 0
|
||||
? formatPrice(shippingPriceCents, cart.currency)
|
||||
: 'Bude upřesněno'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Divider my={2} />
|
||||
<HStack justify="space-between" fontWeight="bold">
|
||||
<Text>Celkem k úhradě</Text>
|
||||
<Text>{formatPrice(grandTotal, cart.currency)}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
width="100%"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Pokračovat k platbě
|
||||
</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
{/* Stripe Payment Modal */}
|
||||
<Modal isOpen={showStripeModal} onClose={() => setShowStripeModal(false)} size="md">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Platba kartou</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
{stripe && stripeClientSecret ? (
|
||||
<Elements
|
||||
stripe={stripe}
|
||||
options={{
|
||||
clientSecret: stripeClientSecret,
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StripePaymentForm
|
||||
clientSecret={stripeClientSecret}
|
||||
onSuccess={() => {
|
||||
setShowStripeModal(false);
|
||||
toast({
|
||||
status: 'success',
|
||||
title: 'Platba úspěšná',
|
||||
description: 'Objednávka byla zaplacena.',
|
||||
});
|
||||
navigate('/objednavka/dekujeme');
|
||||
}}
|
||||
onCancel={() => setShowStripeModal(false)}
|
||||
/>
|
||||
</Elements>
|
||||
) : (
|
||||
<VStack spacing={4}>
|
||||
<Text>Načítání platební brány...</Text>
|
||||
<Spinner />
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckoutPage;
|
||||
@@ -0,0 +1,203 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams, Link as RouterLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getOrder, EshopOrder } from '../services/eshopApi';
|
||||
import { Box, Heading, Text, VStack, HStack, Button, Spinner, Divider, Link, Card, CardBody, Badge, Container } from '@chakra-ui/react';
|
||||
|
||||
const formatPrice = (cents: number, currency: string) => {
|
||||
const value = cents / 100;
|
||||
return new Intl.NumberFormat('cs-CZ', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'paid':
|
||||
return 'green';
|
||||
case 'awaiting_payment':
|
||||
return 'orange';
|
||||
case 'processing':
|
||||
return 'blue';
|
||||
case 'shipped':
|
||||
return 'purple';
|
||||
case 'cancelled':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'paid':
|
||||
return 'Zaplaceno';
|
||||
case 'awaiting_payment':
|
||||
return 'Čeká na platbu';
|
||||
case 'processing':
|
||||
return 'Zpracovává se';
|
||||
case 'shipped':
|
||||
return 'Odesláno';
|
||||
case 'cancelled':
|
||||
return 'Zrušeno';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const OrderSuccessPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const orderIdParam = searchParams.get('order_id');
|
||||
const orderId = orderIdParam ? Number(orderIdParam) : NaN;
|
||||
const enabled = !Number.isNaN(orderId) && orderId > 0;
|
||||
|
||||
const { data, isLoading, isError } = useQuery<EshopOrder>(
|
||||
['eshop-order', orderId],
|
||||
() => getOrder(orderId),
|
||||
{ enabled }
|
||||
);
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<Container maxW="3xl" py={10}>
|
||||
<VStack spacing={6} align="center" textAlign="center">
|
||||
<Heading size="lg" color="green.600">✓ Děkujeme za vaši objednávku!</Heading>
|
||||
<Text color="gray.600">Platba proběhla úspěšně. Shrnutí objednávky momentálně nemáme k dispozici.</Text>
|
||||
<Button as={RouterLink} to="/" colorScheme="blue" size="lg">
|
||||
Zpět do obchodu
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container maxW="3xl" py={10}>
|
||||
<VStack spacing={4} align="center">
|
||||
<Spinner size="lg" />
|
||||
<Text>Načítám vaši objednávku…</Text>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Container maxW="3xl" py={10}>
|
||||
<VStack spacing={6} align="center" textAlign="center">
|
||||
<Heading size="lg" color="orange.600">✓ Děkujeme za vaši objednávku!</Heading>
|
||||
<Text color="gray.600">Platba proběhla, ale nepodařilo se načíst detail objednávky.</Text>
|
||||
<Text color="gray.500" fontSize="sm">Zkontrolujte prosím potvrzovací email nebo se vraťte zpět do obchodu.</Text>
|
||||
<Button as={RouterLink} to="/" colorScheme="blue" size="lg">
|
||||
Zpět do obchodu
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const total = data.total_amount_cents || 0;
|
||||
|
||||
return (
|
||||
<Container maxW="3xl" py={10}>
|
||||
<VStack spacing={8} align="stretch">
|
||||
{/* Success Header */}
|
||||
<Box textAlign="center">
|
||||
<Heading size="lg" color="green.600" mb={2}>✓ Děkujeme za vaši objednávku!</Heading>
|
||||
<Text color="gray.600">Potvrzení objednávky bylo odesláno na vaši emailovou adresu.</Text>
|
||||
</Box>
|
||||
|
||||
{/* Order Details Card */}
|
||||
<Card variant="outline">
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Heading size="md">Detail objednávky</Heading>
|
||||
|
||||
<HStack justify="space-between" wrap="wrap">
|
||||
<Text fontWeight="medium">Číslo objednávky:</Text>
|
||||
<Text fontFamily="mono">{data.order_number}</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between" wrap="wrap">
|
||||
<Text fontWeight="medium">Stav:</Text>
|
||||
<Badge colorScheme={getStatusColor(data.status)}>
|
||||
{getStatusText(data.status)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between" wrap="wrap">
|
||||
<Text fontWeight="medium">Celková částka:</Text>
|
||||
<Text fontSize="lg" fontWeight="bold" color="blue.600">
|
||||
{formatPrice(total, data.currency || 'CZK')}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{data.shipping_method && (
|
||||
<HStack justify="space-between" wrap="wrap">
|
||||
<Text fontWeight="medium">Způsob dopravy:</Text>
|
||||
<Text>
|
||||
{data.shipping_method === 'packeta' ? 'Zásilkovna' : data.shipping_method}
|
||||
{data.shipping_price_cents && data.shipping_price_cents > 0 && (
|
||||
<Text as="span" ml={2} color="gray.500">
|
||||
({formatPrice(data.shipping_price_cents, data.currency || 'CZK')})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Items Card */}
|
||||
{data.items && data.items.length > 0 && (
|
||||
<Card variant="outline">
|
||||
<CardBody>
|
||||
<Heading size="md" mb={4}>Přehled položek</Heading>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{data.items.map((item) => (
|
||||
<HStack key={item.id} justify="space-between" py={2} borderBottom="1px" borderColor="gray.100">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontWeight="medium">{item.name}</Text>
|
||||
<Text fontSize="sm" color="gray.500">Počet: {item.quantity}</Text>
|
||||
</VStack>
|
||||
<Text fontWeight="bold">
|
||||
{formatPrice(item.unit_price_cents * item.quantity, item.currency || data.currency || 'CZK')}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Next Steps */}
|
||||
<Card variant="outline" bg="blue.50">
|
||||
<CardBody>
|
||||
<Heading size="md" mb={3} color="blue.700">Co se stane dál?</Heading>
|
||||
<VStack align="start" spacing={2} color="blue.600">
|
||||
<Text>• 📧 Potvrzení objednávky bylo odesláno na {data.email}</Text>
|
||||
<Text>• 📦 Zpracování objednávky obvykle trvá 1-2 pracovní dny</Text>
|
||||
<Text>• 🚀 O odeslání budete informováni emailem</Text>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<HStack spacing={4} justify="center">
|
||||
<Button as={RouterLink} to="/" colorScheme="blue" size="lg">
|
||||
Pokračovat v nákupu
|
||||
</Button>
|
||||
<Link as={RouterLink} to="/cart" fontSize="sm" color="blue.500" alignSelf="center">
|
||||
Zobrazit košík
|
||||
</Link>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderSuccessPage;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getProduct, addToCart, EshopProductVariant } from '../services/eshopApi';
|
||||
import { Box, Heading, Text, Image, Badge, HStack, VStack, Button, Select, useToast } from '@chakra-ui/react';
|
||||
|
||||
const formatPrice = (cents: number, currency: string) => {
|
||||
const value = cents / 100;
|
||||
return new Intl.NumberFormat('cs-CZ', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const ProductDetailPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const toast = useToast();
|
||||
const { data, isLoading, isError } = useQuery(['eshop-product', slug], () => getProduct(slug || ''), {
|
||||
enabled: !!slug,
|
||||
});
|
||||
const [variantId, setVariantId] = useState<number | undefined>(undefined);
|
||||
|
||||
if (isLoading) return <Text>Načítání produktu…</Text>;
|
||||
if (isError || !data) return <Text>Produkt nebyl nalezen.</Text>;
|
||||
|
||||
const variants = data.variants || [];
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
try {
|
||||
const selected = variants.find((v) => v.id === variantId) as EshopProductVariant | undefined;
|
||||
await addToCart(data.id, selected?.id, 1);
|
||||
toast({ status: 'success', title: 'Přidáno do košíku', description: data.name, duration: 2000 });
|
||||
} catch (e) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se přidat do košíku.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack align="flex-start" spacing={8} flexWrap="wrap">
|
||||
<Box flex="0 0 320px" maxW="100%">
|
||||
<Image
|
||||
src={data.default_image_url || '/images/placeholder-clothing.jpg'}
|
||||
alt={data.name}
|
||||
w="100%"
|
||||
borderRadius="md"
|
||||
mb={4}
|
||||
/>
|
||||
</Box>
|
||||
<VStack align="stretch" spacing={4} flex="1">
|
||||
<Heading size="lg">{data.name}</Heading>
|
||||
<Badge colorScheme="blue" fontSize="md" alignSelf="flex-start">
|
||||
{formatPrice(data.price_cents, data.currency || 'CZK')}
|
||||
</Badge>
|
||||
{data.short_description && (
|
||||
<Text color="gray.700">{data.short_description}</Text>
|
||||
)}
|
||||
{variants.length > 0 && (
|
||||
<Box>
|
||||
<Text fontWeight="medium" mb={1}>Varianta</Text>
|
||||
<Select
|
||||
placeholder="Vyberte variantu"
|
||||
value={variantId ?? ''}
|
||||
onChange={(e) => setVariantId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
maxW="260px"
|
||||
>
|
||||
{variants.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name || v.sku || `Varianta #${v.id}`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
)}
|
||||
<Button colorScheme="blue" onClick={handleAddToCart} maxW="260px">
|
||||
Přidat do košíku
|
||||
</Button>
|
||||
{data.description_html && (
|
||||
<Box mt={4} fontSize="sm" color="gray.700">
|
||||
<div dangerouslySetInnerHTML={{ __html: data.description_html }} />
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailPage;
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
Select,
|
||||
useToast,
|
||||
Switch,
|
||||
Divider,
|
||||
HStack,
|
||||
Spinner,
|
||||
} from '@chakra-ui/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getEshopSettings, updateEshopSettings, getClubInfo, EshopSettings, ClubInfo } from '../services/eshopApi';
|
||||
|
||||
const SetupPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [clubInfo, setClubInfo] = useState<ClubInfo | null>(null);
|
||||
const [formData, setFormData] = useState<EshopSettings>({
|
||||
default_currency: 'CZK',
|
||||
default_country: 'CZ',
|
||||
support_email: '',
|
||||
support_phone: '',
|
||||
terms_url: '',
|
||||
returns_policy_url: '',
|
||||
});
|
||||
|
||||
// Packeta toggle state (will be stored in shipping_options_json)
|
||||
const [packetaEnabled, setPacketaEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Load Club Info to pre-fill defaults
|
||||
const info = await getClubInfo();
|
||||
setClubInfo(info);
|
||||
|
||||
// Load existing settings if any
|
||||
const settings = await getEshopSettings();
|
||||
|
||||
// Merge defaults if settings are empty
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...settings,
|
||||
// If support email/phone are empty in settings, pre-fill from club info
|
||||
support_email: settings.support_email || info.contact_email || '',
|
||||
support_phone: settings.support_phone || info.contact_phone || '',
|
||||
default_country: settings.default_country || info.contact_country || 'CZ',
|
||||
}));
|
||||
|
||||
// Parse packeta enabled state from shipping_options_json
|
||||
if (settings.shipping_options_json) {
|
||||
try {
|
||||
const opts = JSON.parse(settings.shipping_options_json);
|
||||
if (opts.packeta_enabled) {
|
||||
setPacketaEnabled(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse shipping options", e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Setup init failed", error);
|
||||
toast({
|
||||
title: 'Chyba načítání dat',
|
||||
description: 'Nepodařilo se načíst informace o klubu nebo nastavení.',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initData();
|
||||
}, [toast]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Prepare shipping options
|
||||
const shippingOptions = {
|
||||
packeta_enabled: packetaEnabled,
|
||||
};
|
||||
|
||||
const payload: EshopSettings = {
|
||||
...formData,
|
||||
shipping_options_json: JSON.stringify(shippingOptions),
|
||||
};
|
||||
|
||||
await updateEshopSettings(payload);
|
||||
|
||||
toast({
|
||||
title: 'Nastavení uloženo',
|
||||
description: 'E-shop je připraven k použití.',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// Redirect to admin dashboard
|
||||
navigate('/admin');
|
||||
} catch (error) {
|
||||
console.error("Save failed", error);
|
||||
toast({
|
||||
title: 'Chyba ukládání',
|
||||
description: 'Nastavení se nepodařilo uložit.',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box py={10} textAlign="center">
|
||||
<Spinner size="xl" />
|
||||
<Text mt={4}>Načítám konfiguraci...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxW="2xl" py={8}>
|
||||
<VStack spacing={6} align="stretch" bg="white" p={8} borderRadius="lg" boxShadow="md">
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>E-shop Setup Wizard</Heading>
|
||||
<Text color="gray.600">
|
||||
Základní nastavení vašeho e-shopu. Některé údaje jsme předvyplnili z nastavení klubu
|
||||
<strong> {clubInfo?.club_name}</strong>.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Heading size="md">1. Lokalizace a měna</Heading>
|
||||
<HStack spacing={4}>
|
||||
<FormControl id="default_country">
|
||||
<FormLabel>Výchozí země</FormLabel>
|
||||
<Select name="default_country" value={formData.default_country} onChange={handleChange}>
|
||||
<option value="CZ">Česká republika</option>
|
||||
<option value="SK">Slovensko</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl id="default_currency">
|
||||
<FormLabel>Měna</FormLabel>
|
||||
<Select name="default_currency" value={formData.default_currency} onChange={handleChange}>
|
||||
<option value="CZK">CZK (Kč)</option>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
<Heading size="md" mt={4}>2. Zákaznická podpora</Heading>
|
||||
<Text fontSize="sm" color="gray.500">Tyto údaje se zobrazí v patičce a emailech zákazníkům.</Text>
|
||||
<FormControl id="support_email" isRequired>
|
||||
<FormLabel>Email podpory</FormLabel>
|
||||
<Input
|
||||
name="support_email"
|
||||
type="email"
|
||||
value={formData.support_email}
|
||||
onChange={handleChange}
|
||||
placeholder="eshop@mojklub.cz"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id="support_phone">
|
||||
<FormLabel>Telefon podpory</FormLabel>
|
||||
<Input
|
||||
name="support_phone"
|
||||
type="tel"
|
||||
value={formData.support_phone}
|
||||
onChange={handleChange}
|
||||
placeholder="+420 123 456 789"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Heading size="md" mt={4}>3. Obchodní podmínky</Heading>
|
||||
<FormControl id="terms_url">
|
||||
<FormLabel>Odkaz na VOP</FormLabel>
|
||||
<Input
|
||||
name="terms_url"
|
||||
value={formData.terms_url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://www.mojklub.cz/obchodni-podminky"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id="returns_policy_url">
|
||||
<FormLabel>Odkaz na Reklamační řád</FormLabel>
|
||||
<Input
|
||||
name="returns_policy_url"
|
||||
value={formData.returns_policy_url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://www.mojklub.cz/reklamace"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Heading size="md" mt={4}>4. Doprava</Heading>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel htmlFor="packeta-switch" mb="0">
|
||||
Povolit Zásilkovnu (Packeta)
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="packeta-switch"
|
||||
isChecked={packetaEnabled}
|
||||
onChange={(e) => setPacketaEnabled(e.target.checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
{packetaEnabled && (
|
||||
<Text fontSize="xs" color="blue.500">
|
||||
Packeta widget bude aktivní v košíku. Ujistěte se, že máte nastavené PACKETA_ API klíče v .env.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
onClick={handleSave}
|
||||
isLoading={saving}
|
||||
loadingText="Ukládám..."
|
||||
>
|
||||
Dokončit nastavení
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupPage;
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getProducts, addToCart, EshopProduct } from '../services/eshopApi';
|
||||
import { Box, SimpleGrid, Heading, Text, Image, Badge, Button, VStack, HStack, useToast, Link as ChakraLink } from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
const formatPrice = (cents: number, currency: string) => {
|
||||
const value = cents / 100;
|
||||
return new Intl.NumberFormat('cs-CZ', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const ShopHomePage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const { data, isLoading, isError } = useQuery<EshopProduct[]>(['eshop-products'], getProducts);
|
||||
|
||||
const handleAddToCart = async (product: EshopProduct) => {
|
||||
try {
|
||||
const firstVariant = product.variants && product.variants.length > 0 ? product.variants[0] : undefined;
|
||||
await addToCart(product.id, firstVariant?.id, 1);
|
||||
toast({ status: 'success', title: 'Přidáno do košíku', description: product.name, duration: 2000 });
|
||||
} catch (e) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se přidat do košíku.' });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Text>Načítání produktů…</Text>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <Text>Došlo k chybě při načítání produktů.</Text>;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>E-shop bude brzy spuštěn</Heading>
|
||||
<Text color="gray.600">Zatím zde nejsou žádné produkty. Ověřte nastavení e-shopu v administraci.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>Produkty</Heading>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing={6}>
|
||||
{data.map((p) => (
|
||||
<Box key={p.id} borderWidth="1px" borderRadius="md" overflow="hidden" bg="white" _hover={{ boxShadow: 'md' }}>
|
||||
<Box position="relative" paddingTop="70%" overflow="hidden">
|
||||
<Image
|
||||
src={p.default_image_url || '/images/placeholder-clothing.jpg'}
|
||||
alt={p.name}
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
w="100%"
|
||||
h="100%"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<VStack align="stretch" p={4} spacing={2}>
|
||||
<ChakraLink as={RouterLink} to={`/produkt/${p.slug}`} fontWeight="semibold" noOfLines={2}>
|
||||
{p.name}
|
||||
</ChakraLink>
|
||||
{p.short_description && (
|
||||
<Text fontSize="sm" color="gray.600" noOfLines={2}>{p.short_description}</Text>
|
||||
)}
|
||||
<HStack justify="space-between" mt={1}>
|
||||
<Badge colorScheme="blue">{formatPrice(p.price_cents, p.currency || 'CZK')}</Badge>
|
||||
<Button size="sm" colorScheme="blue" onClick={() => handleAddToCart(p)}>
|
||||
Do košíku
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShopHomePage;
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -0,0 +1,220 @@
|
||||
import axios, { AxiosRequestHeaders } from 'axios';
|
||||
|
||||
const baseURL = process.env.REACT_APP_API_URL || '/api/v1/eshop';
|
||||
|
||||
export const eshopApi = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
let cachedSessionToken: string | null = null;
|
||||
|
||||
export function getOrCreateEshopSessionToken(): string {
|
||||
if (cachedSessionToken) {
|
||||
return cachedSessionToken;
|
||||
}
|
||||
|
||||
// In non-browser environments (tests, SSR) generate an in-memory token
|
||||
if (typeof window === 'undefined') {
|
||||
cachedSessionToken = `eshop-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
|
||||
return cachedSessionToken;
|
||||
}
|
||||
|
||||
const key = 'eshop_session_token';
|
||||
const existing = window.localStorage.getItem(key);
|
||||
let token: string;
|
||||
if (existing) {
|
||||
token = existing;
|
||||
} else {
|
||||
if (window.crypto && typeof (window.crypto as any).randomUUID === 'function') {
|
||||
token = (window.crypto as any).randomUUID();
|
||||
} else {
|
||||
token = `eshop-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
|
||||
}
|
||||
window.localStorage.setItem(key, token);
|
||||
}
|
||||
|
||||
// Persist token in a cookie as well so the backend can read it when headers are missing
|
||||
try {
|
||||
const oneYearInSeconds = 60 * 60 * 24 * 365;
|
||||
document.cookie = `eshop_session_token=${token}; path=/; max-age=${oneYearInSeconds}`;
|
||||
} catch {
|
||||
// Ignore cookie errors (e.g. disabled cookies)
|
||||
}
|
||||
|
||||
cachedSessionToken = token;
|
||||
return token;
|
||||
}
|
||||
|
||||
eshopApi.interceptors.request.use((config) => {
|
||||
const token = getOrCreateEshopSessionToken();
|
||||
const headers: AxiosRequestHeaders = (config.headers || {}) as AxiosRequestHeaders;
|
||||
if (!('X-Session-Token' in headers)) {
|
||||
(headers as any)['X-Session-Token'] = token;
|
||||
}
|
||||
config.headers = headers;
|
||||
return config;
|
||||
});
|
||||
|
||||
export interface EshopProductVariant {
|
||||
id: number;
|
||||
sku?: string;
|
||||
name?: string;
|
||||
stock_qty?: number;
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export interface EshopProduct {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
short_description?: string;
|
||||
description_html?: string;
|
||||
price_cents: number;
|
||||
currency: string;
|
||||
default_image_url?: string;
|
||||
gallery_json?: string;
|
||||
tags?: string;
|
||||
variants?: EshopProductVariant[];
|
||||
}
|
||||
|
||||
export interface EshopCartItem {
|
||||
id: number;
|
||||
product_id: number;
|
||||
variant_id?: number;
|
||||
quantity: number;
|
||||
unit_price_cents: number;
|
||||
currency: string;
|
||||
product?: EshopProduct;
|
||||
}
|
||||
|
||||
export interface EshopCart {
|
||||
id: number;
|
||||
currency: string;
|
||||
items: EshopCartItem[];
|
||||
}
|
||||
|
||||
export async function getProducts(): Promise<EshopProduct[]> {
|
||||
const res = await eshopApi.get<{ data: EshopProduct[] }>('/products');
|
||||
return res.data.data;
|
||||
}
|
||||
|
||||
export async function getProduct(slug: string): Promise<EshopProduct> {
|
||||
const res = await eshopApi.get<EshopProduct>(`/products/${encodeURIComponent(slug)}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCart(): Promise<EshopCart> {
|
||||
const res = await eshopApi.get<EshopCart>('/cart');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function addToCart(productId: number, variantId: number | undefined, quantity: number): Promise<void> {
|
||||
await eshopApi.post('/cart/items', {
|
||||
product_id: productId,
|
||||
variant_id: variantId ?? undefined,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCartItem(id: number, quantity: number): Promise<void> {
|
||||
await eshopApi.patch(`/cart/items/${id}`, { quantity });
|
||||
}
|
||||
|
||||
export const removeCartItem = async (id: number): Promise<void> => {
|
||||
await eshopApi.delete(`/cart/items/${id}`);
|
||||
};
|
||||
|
||||
export interface CheckoutRequest {
|
||||
billing_address: any;
|
||||
shipping_address: any;
|
||||
shipping_method: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface CheckoutResponse {
|
||||
order_id: number;
|
||||
order_number: string;
|
||||
payment_provider?: string;
|
||||
payment_redirect_url?: string;
|
||||
client_secret?: string;
|
||||
manual_payment?: boolean;
|
||||
contact_email?: string;
|
||||
}
|
||||
|
||||
export async function checkout(req: CheckoutRequest): Promise<CheckoutResponse> {
|
||||
const res = await eshopApi.post<CheckoutResponse>('/checkout', req);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export const getPacketaWidgetConfig = async (): Promise<{ api_key: string; env: string }> => {
|
||||
const response = await eshopApi.get('/shipping/packeta-widget-config');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface EshopOrderItem {
|
||||
id: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit_price_cents: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface EshopOrder {
|
||||
id: number;
|
||||
order_number: string;
|
||||
status: string;
|
||||
total_amount_cents: number;
|
||||
currency: string;
|
||||
email?: string;
|
||||
shipping_method?: string;
|
||||
shipping_price_cents?: number;
|
||||
items?: EshopOrderItem[];
|
||||
}
|
||||
|
||||
export async function getOrder(id: number): Promise<EshopOrder> {
|
||||
const res = await eshopApi.get<EshopOrder>(`/orders/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// --- Setup & Settings ---
|
||||
|
||||
export interface EshopSettings {
|
||||
id?: number;
|
||||
default_currency: string;
|
||||
supported_currencies?: string;
|
||||
default_country: string;
|
||||
shipping_options_json?: string;
|
||||
terms_url?: string;
|
||||
returns_policy_url?: string;
|
||||
support_email?: string;
|
||||
support_phone?: string;
|
||||
}
|
||||
|
||||
export interface ClubInfo {
|
||||
club_name: string;
|
||||
club_logo_url: string;
|
||||
contact_email: string;
|
||||
contact_phone: string;
|
||||
contact_country: string;
|
||||
primary_color: string;
|
||||
}
|
||||
|
||||
export async function getEshopSettings(): Promise<EshopSettings> {
|
||||
const res = await eshopApi.get<EshopSettings>('/admin/settings');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateEshopSettings(settings: EshopSettings): Promise<EshopSettings> {
|
||||
const res = await eshopApi.put<EshopSettings>('/admin/settings', settings);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getClubInfo(): Promise<ClubInfo> {
|
||||
const res = await eshopApi.get<ClubInfo>('/admin/club-info');
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { extendTheme, ThemeConfig } from '@chakra-ui/react';
|
||||
|
||||
const config: ThemeConfig = {
|
||||
initialColorMode: 'light',
|
||||
useSystemColorMode: false,
|
||||
};
|
||||
|
||||
// For now we use a simple blue theme. In a next step, we can hydrate colors
|
||||
// from the main MyClub settings API so the e-shop matches club branding.
|
||||
export const theme = extendTheme({
|
||||
config,
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#e3f2ff',
|
||||
100: '#b9d4ff',
|
||||
200: '#8fb7ff',
|
||||
300: '#6599ff',
|
||||
400: '#3b7cff',
|
||||
500: '#1a5fe6',
|
||||
600: '#1449b4',
|
||||
700: '#0e3382',
|
||||
800: '#071d51',
|
||||
900: '#020720',
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
global: {
|
||||
body: {
|
||||
bg: 'gray.50',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "src",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user