feat: proklik na stránky podniku ze stránky objednávek
CI / Generate TypeScript types (push) Successful in 9s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 25s
CI / Build client (push) Successful in 37s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 39s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-06-10 19:09:20 +02:00
parent cc09ddbd2c
commit 88b9e20e2d
11 changed files with 154 additions and 36 deletions
@@ -2,17 +2,19 @@ import { useState } from "react";
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { addStore, deleteStore } from "../../../../types";
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { addStore, deleteStore, Store } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
stores: string[];
onStoresChanged: (stores: string[]) => void;
stores: Store[];
onStoresChanged: (stores: Store[]) => void;
};
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
const [newName, setNewName] = useState('');
const [newUrl, setNewUrl] = useState('');
const [heslo, setHeslo] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -22,12 +24,13 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
setError(null);
setLoading(true);
try {
const res = await addStore({ body: { name: newName.trim(), heslo } });
const res = await addStore({ body: { name: newName.trim(), url: newUrl.trim() || undefined, heslo } });
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as string[]);
onStoresChanged(res.data as Store[]);
setNewName('');
setNewUrl('');
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
@@ -44,7 +47,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as string[]);
onStoresChanged(res.data as Store[]);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
@@ -78,14 +81,22 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
<hr />
<h6>Přidat obchod</h6>
<div className="d-flex gap-2 mb-3">
<Form.Control
className="mb-2"
type="text"
placeholder="Název obchodu"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
<div className="d-flex gap-2 mb-3">
<Form.Control
type="url"
placeholder="URL na nabídku (volitelné, např. Bolt Food/Wolt)"
value={newUrl}
onChange={e => setNewUrl(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
Přidat
</Button>
@@ -97,13 +108,20 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
) : (
<ListGroup>
{stores.map(s => (
<ListGroup.Item key={s} className="d-flex justify-content-between align-items-center">
{s}
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-center">
<span>
{s.name}
{s.url && /^https?:\/\//i.test(s.url) && (
<a href={s.url} target="_blank" rel="noopener noreferrer" className="ms-2" title="Otevřít nabídku v nové záložce">
<FontAwesomeIcon icon={faUpRightFromSquare} />
</a>
)}
</span>
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title="Odebrat"
onClick={() => handleRemove(s)}
onClick={() => handleRemove(s.name)}
style={{ cursor: 'pointer' }}
/>
</ListGroup.Item>
+13 -1
View File
@@ -404,7 +404,7 @@ export default function OrderGroupsPage() {
style={{ maxWidth: 260 }}
>
<option value=""> vyberte obchod </option>
{stores.map(s => <option key={s} value={s}>{s}</option>)}
{stores.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</Form.Select>
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
Vytvořit skupinu
@@ -429,6 +429,10 @@ export default function OrderGroupsPage() {
const isLocked = group.state === GroupState.LOCKED;
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
const editingTimes = group.id in editTimes;
// URL na nabídku podniku (pokud ji má dohledatelný obchod vyplněnou).
// Povolíme jen http(s), aby odkaz nemohl být zneužit (např. javascript:).
const rawStoreUrl = stores.find(s => s.name === group.name)?.url;
const storeUrl = rawStoreUrl && /^https?:\/\//i.test(rawStoreUrl) ? rawStoreUrl : undefined;
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
@@ -442,7 +446,15 @@ export default function OrderGroupsPage() {
<Card key={group.id} className="mb-3 fade-in">
<Card.Header className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center gap-2">
{storeUrl ? (
<strong>
<a href={storeUrl} target="_blank" rel="noopener noreferrer" title="Otevřít nabídku v nové záložce">
{group.name}
</a>
</strong>
) : (
<strong>{group.name}</strong>
)}
{stateBadge(group.state)}
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
</div>
+3
View File
@@ -0,0 +1,3 @@
[
"Proklik na nabídku podniku ze stránky objednávek"
]
+2 -2
View File
@@ -49,11 +49,11 @@ export async function getOrderDates(): Promise<string[]> {
export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise<ClientData> {
const stores = await getStores();
if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) {
if (!stores.some(s => s.name.toLowerCase() === name.trim().toLowerCase())) {
throw new Error('Obchod není v seznamu povolených obchodů');
}
const data = await getExtraData(date);
const canonical = stores.find(s => s.toLowerCase() === name.trim().toLowerCase())!;
const canonical = stores.find(s => s.name.toLowerCase() === name.trim().toLowerCase())!.name;
const group: OrderGroup = {
id: crypto.randomUUID(),
name: canonical,
+5 -2
View File
@@ -11,15 +11,18 @@ router.get("/", async (_req, res, next) => {
});
router.post("/add", async (req, res, next) => {
const { name, heslo } = req.body ?? {};
const { name, heslo, url } = req.body ?? {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
}
if (!heslo || typeof heslo !== 'string') {
return res.status(400).json({ error: 'Nebylo předáno heslo' });
}
if (url != null && typeof url !== 'string') {
return res.status(400).json({ error: 'Neplatná URL obchodu' });
}
try {
const stores = await addStore(name, heslo);
const stores = await addStore(name, heslo, url);
res.status(200).json(stores);
} catch (e: any) {
if (e.message === 'UNAUTHORIZED') {
+43 -7
View File
@@ -1,13 +1,29 @@
import { Store } from "../../types/gen/types.gen";
import getStorage from "./storage";
const storage = getStorage();
const STORES_KEY = 'stores';
export async function getStores(): Promise<string[]> {
return (await storage.getData<string[]>(STORES_KEY)) ?? [];
/**
* Vrátí seznam povolených obchodů. Zachovává zpětnou kompatibilitu se starším
* formátem, kdy byly obchody uloženy jako pole řetězců (převede je na objekty).
*/
export async function getStores(): Promise<Store[]> {
const raw = await storage.getData<(string | Store)[]>(STORES_KEY);
if (!raw) {
return [];
}
return raw.map(s => (typeof s === 'string' ? { name: s } : s));
}
export async function addStore(name: string, heslo: string): Promise<string[]> {
/**
* Přidá nový obchod do seznamu povolených.
*
* @param name název obchodu
* @param heslo admin heslo
* @param url volitelná URL na nabídku podniku
*/
export async function addStore(name: string, heslo: string, url?: string): Promise<Store[]> {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED');
@@ -17,21 +33,41 @@ export async function addStore(name: string, heslo: string): Promise<string[]> {
throw new Error('Název obchodu nesmí být prázdný');
}
const stores = await getStores();
if (stores.some(s => s.toLowerCase() === trimmed.toLowerCase())) {
if (stores.some(s => s.name.toLowerCase() === trimmed.toLowerCase())) {
throw new Error('Obchod s tímto názvem již existuje');
}
const updated = [...stores, trimmed];
const trimmedUrl = url?.trim();
if (trimmedUrl) {
// Povolíme pouze http(s), aby URL nemohla být zneužita (např. javascript: → XSS)
let parsed: URL;
try {
parsed = new URL(trimmedUrl);
} catch {
throw new Error('Neplatná URL obchodu');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('URL musí začínat http:// nebo https://');
}
}
const store: Store = trimmedUrl ? { name: trimmed, url: trimmedUrl } : { name: trimmed };
const updated = [...stores, store];
await storage.setData(STORES_KEY, updated);
return updated;
}
export async function removeStore(name: string, heslo: string): Promise<string[]> {
/**
* Odebere obchod ze seznamu povolených (dle názvu).
*
* @param name název obchodu
* @param heslo admin heslo
*/
export async function removeStore(name: string, heslo: string): Promise<Store[]> {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED');
}
const stores = await getStores();
const updated = stores.filter(s => s.toLowerCase() !== name.trim().toLowerCase());
const updated = stores.filter(s => s.name.toLowerCase() !== name.trim().toLowerCase());
await storage.setData(STORES_KEY, updated);
return updated;
}
+35 -6
View File
@@ -1,8 +1,12 @@
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { getStores, addStore, removeStore } from '../stores';
const ADMIN_PW = 'testadmin';
/** Vrátí pole názvů obchodů pro snadné porovnání v testech. */
const names = (stores: { name: string }[]) => stores.map(s => s.name);
beforeEach(() => {
resetMemoryStorage();
process.env.ADMIN_PASSWORD = ADMIN_PW;
@@ -17,12 +21,37 @@ describe('getStores', () => {
const stores = await getStores();
expect(stores).toEqual([]);
});
test('převede starý formát (pole řetězců) na objekty', async () => {
// Simulace dat uložených ve starším formátu (před zavedením URL)
await getStorage().setData('stores', ['McDonald\'s', 'KFC']);
const stores = await getStores();
expect(stores).toEqual([{ name: 'McDonald\'s' }, { name: 'KFC' }]);
});
});
describe('addStore', () => {
test('přidá obchod se správným heslem', async () => {
const stores = await addStore('McDonald\'s', ADMIN_PW);
expect(stores).toContain('McDonald\'s');
expect(names(stores)).toContain('McDonald\'s');
});
test('uloží volitelnou URL a ořízne mezery', async () => {
const stores = await addStore('Bistro', ADMIN_PW, ' https://wolt.com/bistro ');
expect(stores).toContainEqual({ name: 'Bistro', url: 'https://wolt.com/bistro' });
});
test('bez URL uloží obchod bez pole url', async () => {
const stores = await addStore('KFC', ADMIN_PW, ' ');
expect(stores).toContainEqual({ name: 'KFC' });
});
test('odmítne URL s nepovoleným schématem (javascript:)', async () => {
await expect(addStore('Zlo', ADMIN_PW, 'javascript:alert(1)')).rejects.toThrow('http');
});
test('odmítne nevalidní URL', async () => {
await expect(addStore('Zlo', ADMIN_PW, 'nfdjska')).rejects.toThrow('Neplatná URL');
});
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
@@ -47,8 +76,8 @@ describe('addStore', () => {
await addStore('McDonald\'s', ADMIN_PW);
const stores = await addStore('KFC', ADMIN_PW);
expect(stores).toHaveLength(2);
expect(stores).toContain('McDonald\'s');
expect(stores).toContain('KFC');
expect(names(stores)).toContain('McDonald\'s');
expect(names(stores)).toContain('KFC');
});
});
@@ -59,12 +88,12 @@ describe('removeStore', () => {
test('odebere obchod se správným heslem', async () => {
const stores = await removeStore('McDonald\'s', ADMIN_PW);
expect(stores).not.toContain('McDonald\'s');
expect(names(stores)).not.toContain('McDonald\'s');
});
test('case-insensitive odebrání', async () => {
const stores = await removeStore('MCDONALD\'S', ADMIN_PW);
expect(stores).not.toContain('McDonald\'s');
expect(names(stores)).not.toContain('McDonald\'s');
});
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
@@ -73,6 +102,6 @@ describe('removeStore', () => {
test('odebrání neexistujícího obchodu nic nerozbije', async () => {
const stores = await removeStore('Neexistuje', ADMIN_PW);
expect(stores).toContain('McDonald\'s');
expect(names(stores)).toContain('McDonald\'s');
});
});
+4 -1
View File
@@ -14,6 +14,9 @@ post:
name:
description: Název obchodu/restaurace
type: string
url:
description: Volitelná URL na nabídku podniku (např. Bolt Food/Wolt/Foodora)
type: string
heslo:
description: Admin heslo (ADMIN_PASSWORD)
type: string
@@ -25,4 +28,4 @@ post:
schema:
type: array
items:
type: string
$ref: "../../schemas/_index.yml#/Store"
+1 -1
View File
@@ -25,4 +25,4 @@ post:
schema:
type: array
items:
type: string
$ref: "../../schemas/_index.yml#/Store"
+1 -1
View File
@@ -9,4 +9,4 @@ get:
schema:
type: array
items:
type: string
$ref: "../../schemas/_index.yml#/Store"
+15 -1
View File
@@ -79,7 +79,7 @@ ClientData:
description: Seznam povolených obchodů/restaurací pro extra objednávky
type: array
items:
type: string
$ref: "#/Store"
# --- OBĚDY ---
UserLunchChoice:
@@ -708,6 +708,20 @@ ClearMockDataRequest:
$ref: "#/DayIndex"
# --- SKUPINOVÉ OBJEDNÁVKY ---
Store:
description: Povolený obchod/restaurace pro extra objednávky.
type: object
additionalProperties: false
required:
- name
properties:
name:
description: Název obchodu/restaurace
type: string
url:
description: Volitelná URL na nabídku podniku (např. Bolt Food/Wolt/Foodora)
type: string
GroupState:
description: Stav skupiny objednávky
type: string