fix: opravy generování QR kódů, zobrazení také na stránce objednání
CI / Generate TypeScript types (push) Successful in 14s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 25s
CI / Build client (push) Successful in 34s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 49s
CI / Notify (push) Successful in 3s
CI / Generate TypeScript types (push) Successful in 14s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 25s
CI / Build client (push) Successful in 34s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 49s
CI / Notify (push) Successful in 3s
This commit is contained in:
+13
-53
@@ -16,12 +16,12 @@ import Footer from './components/Footer';
|
||||
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Loader from './components/Loader';
|
||||
import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
|
||||
import { getHumanDateTime, isInTheFuture } from './Utils';
|
||||
import NoteModal from './components/modals/NoteModal';
|
||||
import ConfirmModal from './components/modals/ConfirmModal';
|
||||
import PayForAllModal from './components/modals/PayForAllModal';
|
||||
import PendingPayments from './components/PendingPayments';
|
||||
import { useEasterEgg } from './context/eggs';
|
||||
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types';
|
||||
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, generateQr } from '../../types';
|
||||
import { getLunchChoiceName } from './enums';
|
||||
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
||||
// import './FallingLeaves.scss';
|
||||
@@ -78,7 +78,6 @@ function App() {
|
||||
const [dayIndex, setDayIndex] = useState<number>();
|
||||
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
|
||||
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
|
||||
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
|
||||
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
|
||||
const [eggImage, setEggImage] = useState<Blob>();
|
||||
const eggRef = useRef<HTMLImageElement>(null);
|
||||
@@ -888,42 +887,21 @@ function App() {
|
||||
</div>
|
||||
}
|
||||
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
|
||||
{
|
||||
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && (() => {
|
||||
const pizzaQr = data.pendingQrs?.find(qr => qr.creator === data.pizzaDay?.creator);
|
||||
return pizzaQr ? (
|
||||
<div className='qr-code'>
|
||||
<h3>QR platba</h3>
|
||||
<img src={`/api/qr?login=${auth.login}&id=${pizzaQr.id}`} alt='QR kód' />
|
||||
</div>
|
||||
) : null;
|
||||
})()
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{data.pendingQrs && data.pendingQrs.length > 0 &&
|
||||
<div className='pizza-section fade-in mt-4'>
|
||||
<h3>Nevyřízené platby</h3>
|
||||
<p>Máte neuhrazené platby.</p>
|
||||
{data.pendingQrs.map(qr => (
|
||||
<div key={qr.id} className='qr-code mb-3'>
|
||||
<p>
|
||||
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice / 100} Kč)
|
||||
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
|
||||
</p>
|
||||
<img src={`/api/qr?login=${auth.login}&id=${qr.id}`} alt='QR kód' />
|
||||
<div className='mt-2'>
|
||||
<Button variant="success" onClick={() => setDismissQrId(qr.id)}>
|
||||
Zaplatil jsem
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
<PendingPayments
|
||||
pendingQrs={data.pendingQrs}
|
||||
login={auth.login}
|
||||
onDismissed={async () => {
|
||||
const response = await getData({ query: { dayIndex } });
|
||||
if (response.data) {
|
||||
setData(response.data);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
{/* <FallingLeaves
|
||||
@@ -932,24 +910,6 @@ function App() {
|
||||
/> */}
|
||||
<Footer />
|
||||
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
|
||||
<ConfirmModal
|
||||
isOpen={dismissQrId !== null}
|
||||
title="Potvrzení platby"
|
||||
message="Opravdu jste zaplatili? QR kód bude odstraněn."
|
||||
confirmLabel="Zaplatil jsem"
|
||||
confirmVariant="success"
|
||||
onClose={() => setDismissQrId(null)}
|
||||
onConfirm={async () => {
|
||||
if (!dismissQrId) return;
|
||||
const id = dismissQrId;
|
||||
setDismissQrId(null);
|
||||
await dismissQr({ body: { id } });
|
||||
const response = await getData({ query: { dayIndex } });
|
||||
if (response.data) {
|
||||
setData(response.data);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{payForAllLocationKey && data && (
|
||||
<PayForAllModal
|
||||
isOpen
|
||||
|
||||
@@ -109,4 +109,17 @@ export function getHumanDate(date: Date) {
|
||||
export function formatDateString(dateString: string): string {
|
||||
const [year, month, day] = dateString.split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Očistí zprávu (účel platby) pro QR platbu – musí odpovídat serverové logice (qr.ts):
|
||||
* transliteruje diakritiku na základní písmena (š→s, č→c, ...), odstraní znaky mimo
|
||||
* ISO 8859-1 a hvězdičku (oddělovač polí v QR platbě) a ořízne na max. 60 znaků.
|
||||
*/
|
||||
export function sanitizeQrMessage(message: string): string {
|
||||
const sanitized = message
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // diakritika → základní písmeno
|
||||
.replace(/[^\x00-\xff]/g, '') // znaky mimo ISO 8859-1
|
||||
.replace(/\*/g, ''); // '*' je v QR platbě oddělovač
|
||||
return sanitized.length > 60 ? sanitized.substring(0, 60) : sanitized;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { PendingQr, dismissQr } from '../../../types';
|
||||
import { formatDateString } from '../Utils';
|
||||
import ConfirmModal from './modals/ConfirmModal';
|
||||
|
||||
type Props = {
|
||||
pendingQrs?: PendingQr[];
|
||||
login?: string;
|
||||
// Zavolá se po úspěšném potvrzení platby, aby si rodič mohl znovu načíst data
|
||||
onDismissed?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
// Sekce "Nevyřízené platby" – zobrazí QR kódy neuhrazených plateb přihlášeného uživatele
|
||||
// včetně tlačítka "Zaplatil jsem" a potvrzovacího dialogu. Sdíleno hlavní stránkou i stránkou objednávek.
|
||||
export default function PendingPayments({ pendingQrs, login, onDismissed }: Readonly<Props>) {
|
||||
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
|
||||
|
||||
if (!pendingQrs || pendingQrs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='pizza-section fade-in mt-4'>
|
||||
<h3>Nevyřízené platby</h3>
|
||||
<p>Máte neuhrazené platby.</p>
|
||||
{pendingQrs.map(qr => (
|
||||
<div key={qr.id} className='qr-code mb-3'>
|
||||
<p>
|
||||
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice / 100} Kč)
|
||||
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
|
||||
</p>
|
||||
<img src={`/api/qr?login=${login}&id=${qr.id}`} alt='QR kód' />
|
||||
<div className='mt-2'>
|
||||
<Button variant="success" onClick={() => setDismissQrId(qr.id)}>
|
||||
Zaplatil jsem
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ConfirmModal
|
||||
isOpen={dismissQrId !== null}
|
||||
title="Potvrzení platby"
|
||||
message="Opravdu jste zaplatili? QR kód bude odstraněn."
|
||||
confirmLabel="Zaplatil jsem"
|
||||
confirmVariant="success"
|
||||
onClose={() => setDismissQrId(null)}
|
||||
onConfirm={async () => {
|
||||
if (!dismissQrId) return;
|
||||
const id = dismissQrId;
|
||||
setDismissQrId(null);
|
||||
await dismissQr({ body: { id } });
|
||||
await onDismissed?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
|
||||
import { sanitizeQrMessage } from "../../Utils";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@@ -77,7 +78,7 @@ export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, pa
|
||||
const note = d.member.note?.trim();
|
||||
recipients.push({
|
||||
login: d.login,
|
||||
purpose: note ? note.replace(/[^\x00-\xff*]/g, '').replace(/\*/g, '').substring(0, 60) : `Objednávka ${group.name}`.substring(0, 60),
|
||||
purpose: sanitizeQrMessage(note || `Objednávka ${group.name}`),
|
||||
amount: total,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faBasketShopping, faCircleCheck, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember,
|
||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
|
||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes,
|
||||
} from '../../../types';
|
||||
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
|
||||
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||
import { useAuth } from '../context/auth';
|
||||
import { useSettings } from '../context/settings';
|
||||
import Login from '../Login';
|
||||
@@ -17,6 +17,7 @@ import Loader from '../components/Loader';
|
||||
import StoreAdminModal from '../components/modals/StoreAdminModal';
|
||||
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
||||
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
||||
import PendingPayments from '../components/PendingPayments';
|
||||
|
||||
const SLOT = MealSlot.EXTRA;
|
||||
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
@@ -70,7 +71,11 @@ export default function OrderGroupsPage() {
|
||||
stores: newData.stores ?? prev?.stores,
|
||||
}));
|
||||
});
|
||||
return () => { socket.off(EVENT_MESSAGE); };
|
||||
// Nová nevyřízená platba (QR kód) – připojíme do dat, aby se zobrazila i bez znovunačtení stránky
|
||||
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
|
||||
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
|
||||
});
|
||||
return () => { socket.off(EVENT_MESSAGE); socket.off(EVENT_PENDING_QR); };
|
||||
}, [socket]);
|
||||
|
||||
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
|
||||
@@ -545,6 +550,13 @@ export default function OrderGroupsPage() {
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nevyřízené platby přihlášeného uživatele */}
|
||||
<PendingPayments
|
||||
pendingQrs={data.pendingQrs}
|
||||
login={auth.login}
|
||||
onDismissed={fetchData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"Zobrazení neuhrazených plateb i na stránce objednávek",
|
||||
"Oprava duplicitního zobrazení QR kódu u Pizza day",
|
||||
"Odstranění diakritiky v platebních QR kódech"
|
||||
]
|
||||
+19
-5
@@ -44,6 +44,24 @@ function createStorageKey(customerName: string, id: string): string {
|
||||
return `qr_${nameHash}_${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Očistí zprávu (účel platby) pro QR platbu:
|
||||
* - transliteruje diakritiku na základní písmena (š→s, č→c, ř→r, ...)
|
||||
* - odstraní zbylé znaky mimo ISO 8859-1
|
||||
* - odstraní '*', který v QR platbě slouží jako oddělovač polí
|
||||
* - ořízne na max. 60 znaků
|
||||
*
|
||||
* @param message původní zpráva
|
||||
* @returns očištěná zpráva vhodná pro QR platbu
|
||||
*/
|
||||
export function sanitizeQrMessage(message: string): string {
|
||||
const sanitized = message
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // diakritika → základní písmeno
|
||||
.replace(/[^\x00-\xff]/g, '') // znaky mimo ISO 8859-1
|
||||
.replace(/\*/g, ''); // '*' je v QR platbě oddělovač
|
||||
return sanitized.length > 60 ? sanitized.substring(0, 60) : sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vygeneruje a uloží obrázek platebního QR kódu do storage (Redis/JSON).
|
||||
* Data přežijí redeploy — není třeba persistentní filesystém.
|
||||
@@ -56,11 +74,7 @@ function createStorageKey(customerName: string, id: string): string {
|
||||
* @param id unikátní identifikátor (UUID) tohoto QR kódu
|
||||
*/
|
||||
export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise<void> {
|
||||
// Zpráva nesmí obsahovat '*' a znaky mimo ISO 8859-1; délka max. 60 znaků
|
||||
message = message.replace(/[^\x00-\xff]/g, '').replace(/\*/g, '');
|
||||
if (message.length > 60) {
|
||||
message = message.substring(0, 60);
|
||||
}
|
||||
message = sanitizeQrMessage(message);
|
||||
const payload = {
|
||||
iban: convertBbanToIban(bankAccountNumber),
|
||||
amount,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { convertBbanToIban } from '../qr';
|
||||
import { convertBbanToIban, sanitizeQrMessage } from '../qr';
|
||||
|
||||
test('konverze BBAN s prefixem na IBAN', () => {
|
||||
// Číslo účtu 19-2000145399/0800 (Česká spořitelna) → CZ6508000000192000145399
|
||||
@@ -34,3 +34,26 @@ test('výsledek vždy začíná CZ', () => {
|
||||
expect(convertBbanToIban('100-2000145399/0300')).toMatch(/^CZ/);
|
||||
expect(convertBbanToIban('2000145399/0100')).toMatch(/^CZ/);
|
||||
});
|
||||
|
||||
test('sanitizace zprávy – diakritika se transliteruje na základní písmena', () => {
|
||||
expect(sanitizeQrMessage('Pizza Šunková')).toBe('Pizza Sunkova');
|
||||
expect(sanitizeQrMessage('čaj a káva')).toBe('caj a kava');
|
||||
expect(sanitizeQrMessage('Žížala, řeřicha, ďábel')).toBe('Zizala, rericha, dabel');
|
||||
});
|
||||
|
||||
test('sanitizace zprávy – hvězdička se odstraní', () => {
|
||||
expect(sanitizeQrMessage('Pizza *akce* 1+1')).toBe('Pizza akce 1+1');
|
||||
expect(sanitizeQrMessage('***')).toBe('');
|
||||
});
|
||||
|
||||
test('sanitizace zprávy – znaky mimo ISO 8859-1 se odstraní', () => {
|
||||
// Emoji a CJK znaky nemají ASCII ekvivalent → zmizí, zbytek zůstane
|
||||
expect(sanitizeQrMessage('Oběd 🍕 hotovo')).toBe('Obed hotovo');
|
||||
// Znaky v rozsahu ISO 8859-1 (např. § ° é) zůstanou zachovány
|
||||
expect(sanitizeQrMessage('Cena 100°C § café')).toBe('Cena 100°C § cafe');
|
||||
});
|
||||
|
||||
test('sanitizace zprávy – ořez na 60 znaků', () => {
|
||||
const long = 'a'.repeat(70);
|
||||
expect(sanitizeQrMessage(long)).toHaveLength(60);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user