Merge remote-tracking branch 'origin/main'

This commit is contained in:
mathusan
2025-06-15 17:27:05 +02:00
17 changed files with 260 additions and 107 deletions

View File

@@ -0,0 +1,36 @@
package de.htwsaar.webshop.cronjob;
import de.htwsaar.webshop.service.SessionService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import static de.htwsaar.webshop.util.TimeUtil.MILLIS_TO_WEEK;
/**
* CronJob for deleting expired sessions in fixed intervals
*/
@Component
@Slf4j
public class ExpiredSessionDeleteJob {
private final SessionService sessionService;
/**
* Constructor of the class
* @param sessionService used in the class
*/
public ExpiredSessionDeleteJob(SessionService sessionService) {
this.sessionService = sessionService;
}
/**
* Method running the job calling the referring service method
*/
@Scheduled(fixedRate = MILLIS_TO_WEEK)
public void runFixedRateTask() {
log.info("Deleting expired sessions...");
sessionService.deleteExpired();
log.info("Deleted expired sessions.");
}
}

View File

@@ -13,4 +13,6 @@ public interface SessionRepository extends JpaRepository<Session, Long> {
Session findByAccount(Account account);
Session getSessionByToken(UUID token);
void deleteSessionsByTimeoutBefore(Long timeoutBefore);
}

View File

@@ -19,4 +19,6 @@ public interface SessionService {
boolean isValid(UUID token, String email);
boolean isAdmin(UUID token, String email);
void deleteExpired();
}

View File

@@ -57,7 +57,7 @@ public class SessionServiceImpl implements SessionService {
if (!session.getAccount().equals(accountEmail)) {
return false;
}
if (session.getTimeout() >= System.currentTimeMillis()) {
if (session.getTimeout() <= System.currentTimeMillis()) {
log.info("Session with email {} is expired", email);
delete(session);
return false;
@@ -92,4 +92,9 @@ public class SessionServiceImpl implements SessionService {
log.info("Session with email {} allowed to access Admin Services", email);
return true;
}
@Override
public void deleteExpired() {
sessionRepository.deleteSessionsByTimeoutBefore(System.currentTimeMillis());
}
}

View File

@@ -46,7 +46,7 @@ public class TimeUtil {
}
public static long nowMonthsAgo(long months) {
return LocalDateTime.now().minusMonths(months).atZone(ZoneId.systemDefault()).toEpochSecond();
return LocalDateTime.now().minusMonths(months).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
public static long monthLength(long epoch) {

View File

@@ -107,5 +107,6 @@
"discount100": "Rabatt in %",
"deleteProduct": "Produkt löschen",
"description": "Beschreibung",
"images": "Bilder"
"images": "Bilder",
"loggedInAs": "Angemeldet als"
}

View File

@@ -107,5 +107,6 @@
"discount100": "Discount in %",
"deleteProduct": "Delete Product",
"description": "Description",
"images": "Images"
"images": "Images",
"loggedInAs": "Logged in as"
}

View File

@@ -21,6 +21,7 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => {
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const useAccount = () => {
const context = useContext(AccountContext);
if (!context) {

View File

@@ -44,6 +44,7 @@ export const BasketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const useBasket = () => {
const context = useContext(BasketContext);
if (!context) {

View File

@@ -4,8 +4,13 @@ import {Box, Button, IconButton, Toolbar, useTheme} from "@mui/material";
import Item from "../../components/Item";
import {useTranslation} from "react-i18next";
import {DataGrid, GridColDef, GridRowId, GridRowSelectionModel} from "@mui/x-data-grid";
import {useState} from "react";
import {useEffect, useState} from "react";
import {Gauge, gaugeClasses} from "@mui/x-charts";
import {useAccount} from "../AccountProvider.tsx";
import {useQuery} from "@tanstack/react-query";
import {fetchItems} from "../query/Queries.tsx";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
export default function ItemsInfo() {
const theme = useTheme();
@@ -56,37 +61,25 @@ export default function ItemsInfo() {
console.log("IconEdit", item);
}
const _rows: Item[] = [
{
id: "1",
uuid: "uuid123",
name: "Item",
description: "Super duper geil <b>wichtiger text </b>",
price100: 1000,
stock: 100,
stockExpected: 1200,
category: "garden",
rating: 7,
discount100: 21,
},
{
id: "2",
uuid: "uuid12312412",
name: "Schlauch",
description: "Schlauchiger Schlauch für Schlauchige Angelegenheiten",
price100: 10,
stock: 50,
stockExpected: 100,
category: "technicalComponents",
rating: 10,
discount100: 21,
},
]
//TODO: get per REST
const [rows, setRows] = useState<Item[]>(_rows);
const [rows, setRows] = useState<Item[]>([]);
const [selectedRows, setSelectedRows] = useState<Set<GridRowId>>(new Set());
const {user: loginData} = useAccount();
const { data } = useQuery({
queryKey: ["fetchItems", loginData],
queryFn: () => fetchItems(loginData? loginData : {email: "", password: "", session: "", customerId: -1}),
retry: 3,
retryDelay: 1000,
});
useEffect(() => {
if (data) {
setRows(data);
}
}, [data]);
const handleSelectionChange = (newSelection: GridRowSelectionModel) => {
setSelectedRows(newSelection.ids);
};
@@ -112,7 +105,7 @@ export default function ItemsInfo() {
{
field: 'name',
headerName: t('name'),
width: 150,
width: 200,
editable: true,
},
{
@@ -120,6 +113,7 @@ export default function ItemsInfo() {
headerName: t('category'),
width: 150,
editable: true,
valueFormatter: (val) => t(val),
},
{
field: 'description',
@@ -132,7 +126,8 @@ export default function ItemsInfo() {
headerName: t('price100'),
width: 100,
editable: true,
type: 'number'
type: 'number',
valueFormatter: (val) => (val / 100).toFixed(2),
},
{
field: 'discount100',

View File

@@ -1,7 +1,7 @@
import AdbIcon from '@mui/icons-material/Adb';
import MenuIcon from '@mui/icons-material/Menu';
import SearchIcon from '@mui/icons-material/Search';
import { Autocomplete, TextField } from '@mui/material';
import { Autocomplete, Badge, TextField } from '@mui/material';
import AppBar from '@mui/material/AppBar';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
@@ -22,15 +22,22 @@ import { useAccount } from '../AccountProvider';
import { fetchItemList } from '../query/Queries';
import LoginDialog from './LoginDialog';
import './NavBar.css';
import { useBasket } from '../BasketProvider';
export default function NavBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>(null);
const [anchorElUser, setAnchorElUser] = React.useState<null | HTMLElement>(null);
const [avatarName, setAvatarName] = React.useState<string>(''); // Für Avatar-Tooltip
const { user, logout } = useAccount();
const { basket } = useBasket();
const totalQuantity = basket?.reduce((sum, item) => sum + item.quantity, 0) ?? 0;
const [loginOpen, setLoginOpen] = React.useState(false);
const [loginData, setLoginData] = React.useState({ password: '', email: '', customerId: 0 });
@@ -42,6 +49,11 @@ export default function NavBar() {
const pages = pageKeys.map(key => ({ key, label: t(key) }));
const settings = user
? [
{
key: 'email',
label: `${t('loggedInAs')}: ${user.email}`,
disabled: true // wir nutzen dieses Flag gleich zur Erkennung
},
{ key: 'account', label: t('account') },
{ key: 'orders', label: t('orders') },
{ key: 'logout', label: t('logout') }
@@ -96,6 +108,13 @@ export default function NavBar() {
setItemNames(items.map((item) => item.name));
}, [items]);
React.useEffect(() => {
// Setze den Avatar-Namen, wenn der Benutzer angemeldet ist
if (user) {
setAvatarName(user.email.toUpperCase());
}
}, [user]);
const handleSearch = (_: React.SyntheticEvent, value: string | null) => {
if (!value) {
@@ -178,15 +197,36 @@ export default function NavBar() {
<Box sx={{ display: "flex", alignItems: "center", gap: 2, marginLeft: 'auto' }}>
<Box sx={{ display: { xs: "none", md: "flex" }, gap: 2 }}>
{pages.map(({ key, label }) => (
<Button
key={key}
onClick={() => handleCloseNavMenu(key)}
sx={{ color: "white", fontWeight: 500 }}
>
{label}
</Button>
))}
{pages.map(({ key, label }) => {
if (key === 'checkout') {
return (
<Button
key={key}
onClick={() => handleCloseNavMenu(key)}
sx={{ color: "white", fontWeight: 500 }}
>
<Badge
badgeContent={totalQuantity}
color="error"
overlap="rectangular"
showZero={false}
>
{label}
</Badge>
</Button>
);
}
return (
<Button
key={key}
onClick={() => handleCloseNavMenu(key)}
sx={{ color: "white", fontWeight: 500 }}
>
{label}
</Button>
);
})}
</Box>
<Box sx={{ display: { xs: "flex", md: "none" } }}>
@@ -216,7 +256,7 @@ export default function NavBar() {
<ThemeToggle />
<Tooltip title={t('openSettings')} placement='bottom-end'>
<IconButton onClick={handleOpenUserMenu} sx={{ p: 0 }}>
<Avatar alt="Florian Speicher" src="/static/images/avatar/2.jpg" />
<Avatar alt={avatarName} src="/static/images/avatar/2.jpg" />
</IconButton>
</Tooltip>
<Menu
@@ -226,8 +266,14 @@ export default function NavBar() {
open={Boolean(anchorElUser)}
onClose={() => setAnchorElUser(null)}
>
{settings.map(({ key, label }) => (
<MenuItem key={key} onClick={() => handleCloseUserMenu(key)}>
{settings.map(({ key, label, disabled }) => (
<MenuItem
key={key}
onClick={() => {
if (!disabled) handleCloseUserMenu(key);
}}
disabled={disabled}
>
<Typography sx={{ textAlign: "center" }}>{label}</Typography>
</MenuItem>
))}

View File

@@ -59,11 +59,14 @@ export default function ProductInfo({ item }: { item: Item }) {
const fetchImage = async () => {
try {
const response = await fetch(`http://localhost:8085/image?uuid=${item.uuid}`);
const data = await response.text();
var data = await response.text();
if(data.length == 0) {
console.error("Got emtpy picture for article ", item.uuid);
}
setImageUrl("data:image/jpeg;base64," + data);
if(!data.startsWith("data:image/")) {
data = "data:image/jpeg;base64," + data
}
setImageUrl(data);
} catch (error) {
console.error("Fehler beim Laden des Bildes:", error);
}

View File

@@ -157,4 +157,12 @@ export const fetchAccounts = async (loginData: User) => {
throw new Error("Login failed");
}
return response.json();
};
export const fetchItems = async (loginData: User) => {
const response = await fetch("http://localhost:8085/article/all?email=" + loginData.email + "&session=" + loginData.session);
if (!response.ok) {
throw new Error("Login failed");
}
return response.json();
};

View File

@@ -15,17 +15,17 @@ import {
TextField,
Typography
} from '@mui/material';
import { useMutation, useQuery } from '@tanstack/react-query';
import { TFunction } from 'i18next';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from "react-i18next";
import { useNavigate } from 'react-router-dom';
import Item from '../components/Item';
import { BasketItem, useBasket } from '../helper/BasketProvider';
import { OrderStatusEnum, ShippingDetails, SubmitOrder } from '../components/Order';
import { useAccount } from '../helper/AccountProvider';
import { useQuery, useMutation } from '@tanstack/react-query';
import { submitCustomer, submitOrder } from '../helper/query/Queries';
import { CustomerType } from '../components/Account';
import Item from '../components/Item';
import { OrderStatusEnum, SubmitOrder } from '../components/Order';
import { useAccount } from '../helper/AccountProvider';
import { BasketItem, useBasket } from '../helper/BasketProvider';
import { fetchCustomer, submitCustomer, submitOrder } from '../helper/query/Queries';
function getDiscountedPrice(item: Item): number {
return (item.price100 / 100 * (100-item.discount100)/100);
@@ -70,26 +70,17 @@ export default function Payment() {
const { basket, clearBasket } = useBasket();
const navigator = useNavigate();
const [activeStep, setActiveStep] = useState(0);
const [shippingDetails, setShippingDetails] = useState<ShippingDetails>({
firstName: '',
lastName: '',
telefon: '',
const [shippingDetails, setShippingDetails] = useState<CustomerType>({
id: 0, // This will be set by the backend or user data
name: '',
surname: '',
address: '',
postalCode: '',
city: '',
zip: '',
country: 'Deutschland',
});
const [orderNumber, setOrderNumber] = useState<string | null>(null);
const steps = [t('reviewCart'), t('shippingDetails'), t('payment'), t('orderSummary')];
const { user } = useAccount();
const customerData: CustomerType = {
id: 0,
name: shippingDetails.firstName,
surname: shippingDetails.lastName,
address: shippingDetails.address,
country: shippingDetails.country,
zip: shippingDetails.postalCode,
};
const submitOrderData: SubmitOrder = {
id: 0, // This will be set by the backend
@@ -104,8 +95,8 @@ export default function Payment() {
};
const { refetch: refetchCustomer } = useQuery({
queryKey: ["submitCustomer", customerData],
queryFn: () => submitCustomer(customerData),
queryKey: ["submitCustomer", shippingDetails],
queryFn: () => submitCustomer(shippingDetails),
retry: 0,
retryDelay: 1000,
enabled: false,
@@ -117,6 +108,28 @@ export default function Payment() {
</Alert>
};
const { refetch: customerData } = useQuery<CustomerType>({
queryKey: ['fetchCustomer', user?.customerId],
queryFn: () => fetchCustomer(user?.customerId || 0), // Funktion zum Abrufen der Kundendaten
enabled: false
});
useEffect(() => {
const fetchShippingDetails = async () => {
if (user) {
try {
const userShippingDetails = (await customerData()).data;
setShippingDetails(userShippingDetails || shippingDetails);
} catch (error) {
console.error("Fehler beim Laden der Kundendaten:", error);
}
}
};
fetchShippingDetails();
}, [user, customerData, shippingDetails]);
// Verwende useMutation statt useQuery für submitOrder
const { mutateAsync: submitOrderMutation } = useMutation({
mutationFn: (orderData: SubmitOrder) => submitOrder(orderData),
@@ -174,11 +187,10 @@ export default function Payment() {
// Hilfsfunktion prüfen, ob alle Pflichtfelder ausgefüllt sind
const isShippingDetailsValid = () => {
return (
shippingDetails.firstName.trim() !== '' &&
shippingDetails.lastName.trim() !== '' &&
shippingDetails.name.trim() !== '' &&
shippingDetails.surname.trim() !== '' &&
shippingDetails.address.trim() !== '' &&
shippingDetails.postalCode.trim() !== '' &&
shippingDetails.city.trim() !== '' &&
shippingDetails.zip.trim() !== '' &&
shippingDetails.country.trim() !== ''
);
};
@@ -210,8 +222,8 @@ export default function Payment() {
<TextField
fullWidth
label={t('firstName')}
name="firstName"
value={shippingDetails.firstName}
name="name"
value={shippingDetails.name}
onChange={handleInputChange}
required
/>
@@ -219,21 +231,12 @@ export default function Payment() {
<TextField
fullWidth
label={t('lastName')}
name="lastName"
value={shippingDetails.lastName}
name="surname"
value={shippingDetails.surname}
onChange={handleInputChange}
required
/>
<TextField
fullWidth
label={t('phone')}
name="telefon"
value={shippingDetails.telefon}
onChange={handleInputChange}
type='number'
/>
<TextField
fullWidth
label={t('address')}
@@ -246,18 +249,8 @@ export default function Payment() {
<TextField
fullWidth
label={t('postalCode')}
name="postalCode"
value={shippingDetails.postalCode}
onChange={handleInputChange}
required
type='number'
/>
<TextField
fullWidth
label={t('city')}
name="city"
value={shippingDetails.city}
name="zip"
value={shippingDetails.zip}
onChange={handleInputChange}
required
/>
@@ -299,10 +292,9 @@ export default function Payment() {
<Divider sx={{ my: 2 }} />
<Typography variant="h6">{t('shippingDetails')}:</Typography>
<Typography variant="body2">
{shippingDetails.firstName} {shippingDetails.lastName}<br />
{shippingDetails.name} {shippingDetails.surname}<br />
{shippingDetails.address}<br />
{shippingDetails.postalCode} {shippingDetails.city}<br />
{shippingDetails.country}
{shippingDetails.zip} {shippingDetails.country}<br />
</Typography>
<Divider sx={{ my: 2 }} />
<Typography variant="h6">{t('orderedItems')}:</Typography>

View File

@@ -39,9 +39,9 @@ Webshop-Projekt für htw saar Digitale Produktionssysteme
### 🤮 Windows
- TODO: make script
- Check if in parent folder, because after exit script is in frontend folder. So retry after cd .. only possible
```shell
./start.ps1
```
# Contributors

60
start.ps1 Normal file
View File

@@ -0,0 +1,60 @@
# Funktion zur Bereinigung beim Beenden
function OnExit {
Write-Host "`n[DPS] Cleaning up..."
if ($backendProcess -and !$backendProcess.HasExited) {
$backendProcess.Kill()
}
if ($frontendProcess -and !$frontendProcess.HasExited) {
$frontendProcess.Kill()
}
Write-Host "[DPS] Cleaned up..."
# Einen Ordner höher gehen
Set-Location (Resolve-Path "..").Path
Write-Host "[DPS] Returned to parent directory: $(Get-Location)"
exit
}
Write-Host "[DPS] trapped"
# Backend starten
Set-Location ./00-backend
$backendOutLog = "../backend_latest.out.log"
$backendErrLog = "../backend_latest.err.log"
$backendProcess = Start-Process "java" -ArgumentList "-jar", "target/webshop-0.0.1-SNAPSHOT.jar" `
-RedirectStandardOutput $backendOutLog `
-RedirectStandardError $backendErrLog `
-NoNewWindow -PassThru
Write-Host "[DPS] Backend started with PID $($backendProcess.Id)"
# Frontend starten (ohne cmd.exe)
Set-Location ../01-frontend
$frontendOutLog = "../frontend_latest.out.log"
$frontendErrLog = "../frontend_latest.err.log"
$frontendProcess = Start-Process "node" `
-ArgumentList "node_modules/.bin/react-scripts", "start" `
-RedirectStandardOutput $frontendOutLog `
-RedirectStandardError $frontendErrLog `
-NoNewWindow -PassThru
Write-Host "[DPS] Frontend started with PID $($frontendProcess.Id)"
Write-Host "[DPS] Ctrl+C to stop"
Register-EngineEvent PowerShell.Exiting -Action { OnExit } -SupportEvent
try {
while ($true) {
Start-Sleep -Seconds 1
if ($backendProcess.HasExited -and $frontendProcess.HasExited) {
break
}
}
}
catch {
if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) {
# This block will not execute on CTRL-C
}
}