This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
+2
View File
@@ -0,0 +1,2 @@
REACT_APP_API_URL=/api/v1/eshop
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_TYooMQauvdEDq54NiTphI7jx
+2
View File
@@ -0,0 +1,2 @@
REACT_APP_API_URL=/api/v1/eshop
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_...
+45
View File
@@ -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;"]
+10
View File
@@ -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"
]
}
+1
View File
@@ -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
+25
View File
@@ -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;
}
}
+16688
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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"
]
}
}
+13
View File
@@ -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>
+67
View File
@@ -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">
&copy; {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ý email.',
},
]);
} 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 eshopu. <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;
+33
View File
@@ -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;
+113
View File
@@ -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;
+475
View File
@@ -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;
+251
View File
@@ -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 ()</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;
+86
View File
@@ -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
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+220
View File
@@ -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;
}
+33
View File
@@ -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',
},
},
},
});
+21
View File
@@ -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"]
}