2 Commits

Author SHA1 Message Date
c2a2659bc4 feat: trvalé zobrazení QR kódu do ručního zavření (#31)
QR kódy pro platbu za pizza day jsou nyní zobrazeny persistentně
i po následující dny, dokud uživatel nepotvrdí platbu tlačítkem
"Zaplatil jsem". Nevyřízené QR kódy jsou uloženy per-user v storage
a zobrazeny v sekci "Nevyřízené platby".
2026-02-04 17:11:45 +01:00
f36afe129a feat: podpora per-user notifikací s Discord, ntfy a Teams (#39)
Uživatelé mohou v nastavení konfigurovat vlastní webhook URL/topic
pro Discord, MS Teams a ntfy, a zvolit události k odběru.
Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
2026-02-04 17:08:23 +01:00
8 changed files with 1626 additions and 188 deletions

View File

@@ -628,77 +628,56 @@ input[type="text"], input[type="email"], input[type="password"] {
tbody tr {
transition: var(--luncher-transition);
border-bottom: 1px solid var(--luncher-border-light);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child {
&:last-child td {
border-bottom: none;
td {
border-bottom: none;
}
}
}
td {
padding: 12px 16px;
border: none;
border-color: var(--luncher-border-light);
color: var(--luncher-text);
white-space: nowrap;
vertical-align: middle;
}
.user-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
ul {
padding: 0;
margin: 8px 0 0 20px;
.user-actions {
display: flex;
gap: 8px;
align-items: center;
white-space: nowrap;
li {
color: var(--luncher-text-secondary);
font-size: 0.9rem;
margin-bottom: 4px;
}
}
.food-choices {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
gap: 6px;
}
.food-choice-item {
display: inline-flex;
background: var(--luncher-primary-light);
padding: 8px 12px;
border-radius: var(--luncher-radius-sm);
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: var(--luncher-text-secondary);
.action-icon {
opacity: 0;
transition: var(--luncher-transition);
}
&:hover .action-icon {
opacity: 1;
}
justify-content: space-between;
font-size: 0.9rem;
border-left: 3px solid var(--luncher-primary);
}
.food-choice-name {
color: var(--luncher-text-secondary);
font-weight: 400;
color: var(--luncher-text);
font-weight: 500;
}
}

View File

@@ -544,16 +544,12 @@ function App() {
const isBuyer = userPayload?.isBuyer || false;
return <tr key={entry[0]}>
<td>
<div className="user-row">
<div className="user-info">
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
</span>}
<strong>{login}</strong>
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
</div>
<div className="user-actions">
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
<FontAwesomeIcon onClick={() => {
markAsBuyer();
@@ -579,11 +575,10 @@ function App() {
doRemoveChoices(key as LunchChoice);
}} className='action-icon' icon={faTrashCan} />
</span>}
</div>
</div>
{userChoices && userChoices.length > 0 && food && (
</td>
{userChoices?.length && food ? <td>
<div className="food-choices">
{userChoices.map(foodIndex => {
{userChoices?.map(foodIndex => {
const restaurantKey = key as Restaurant;
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
return <div key={foodIndex} className="food-choice-item">
@@ -597,8 +592,7 @@ function App() {
</div>
})}
</div>
)}
</td>
</td> : null}
</tr>
}
)}

View File

@@ -50,12 +50,6 @@ paths:
$ref: "./paths/pizzaDay/updatePizzaDayNote.yml"
/pizzaDay/updatePizzaFee:
$ref: "./paths/pizzaDay/updatePizzaFee.yml"
/pizzaDay/dismissQr:
$ref: "./paths/pizzaDay/dismissQr.yml"
# Notifikace (/api/notifications)
/notifications/settings:
$ref: "./paths/notifications/settings.yml"
# Easter eggy (/api/easterEggs)
/easterEggs:

527
types/gen/sdk.gen.ts Normal file
View File

@@ -0,0 +1,527 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { LoginData, LoginResponse, GetPizzaQrData, GetPizzaQrResponse, GetDataData, GetDataResponse, AddChoiceData, AddChoiceResponse, RemoveChoiceData, RemoveChoiceResponse, UpdateNoteData, UpdateNoteResponse, RemoveChoicesData, RemoveChoicesResponse, ChangeDepartureTimeData, ChangeDepartureTimeResponse, JdemeObedData, SetBuyerData, CreatePizzaDayData, DeletePizzaDayData, LockPizzaDayData, UnlockPizzaDayData, FinishOrderData, FinishDeliveryData, AddPizzaData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData, GetEasterEggData, GetEasterEggResponse, GetEasterEggImageData, GetEasterEggImageResponse, GetStatsData, GetStatsResponse, GetVotesData, GetVotesResponse, UpdateVoteData, GetVotingStatsData, GetVotingStatsResponse, GetNotificationSettingsData, GetNotificationSettingsResponse, UpdateNotificationSettingsData, DismissQrData } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client;
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
};
/**
* Přihlášení uživatele
*/
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<LoginResponse, unknown, ThrowOnError>({
url: '/login',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Získání QR kódu pro platbu za Pizza day
*/
export const getPizzaQr = <ThrowOnError extends boolean = false>(options: Options<GetPizzaQrData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<GetPizzaQrResponse, unknown, ThrowOnError>({
url: '/qr',
...options
});
};
/**
* Načtení klientských dat pro aktuální nebo předaný den
*/
export const getData = <ThrowOnError extends boolean = false>(options?: Options<GetDataData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetDataResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/data',
...options
});
};
/**
* Přidání či nahrazení volby uživatele pro zvolený den/podnik
*/
export const addChoice = <ThrowOnError extends boolean = false>(options: Options<AddChoiceData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<AddChoiceResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/food/addChoice',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Odstranění jednoho zvoleného jídla uživatele pro zvolený den/podnik
*/
export const removeChoice = <ThrowOnError extends boolean = false>(options: Options<RemoveChoiceData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<RemoveChoiceResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/food/removeChoice',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Nastavení poznámky k volbě uživatele
*/
export const updateNote = <ThrowOnError extends boolean = false>(options: Options<UpdateNoteData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<UpdateNoteResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/food/updateNote',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Odstranění volby uživatele pro zvolený den/podnik, včetně případných jídel
*/
export const removeChoices = <ThrowOnError extends boolean = false>(options: Options<RemoveChoicesData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<RemoveChoicesResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/food/removeChoices',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Úprava preferovaného času odchodu do aktuálně zvoleného podniku.
*/
export const changeDepartureTime = <ThrowOnError extends boolean = false>(options: Options<ChangeDepartureTimeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<ChangeDepartureTimeResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/food/changeDepartureTime',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Odeslání notifikací "jdeme na oběd" dle konfigurace.
*/
export const jdemeObed = <ThrowOnError extends boolean = false>(options?: Options<JdemeObedData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/food/jdemeObed',
...options
});
};
/**
* Nastavení/odnastavení aktuálně přihlášeného uživatele jako objednatele pro stav "Budu objednávat" pro aktuální den.
*/
export const setBuyer = <ThrowOnError extends boolean = false>(options?: Options<SetBuyerData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/food/updateBuyer',
...options
});
};
/**
* Založení pizza day.
*/
export const createPizzaDay = <ThrowOnError extends boolean = false>(options?: Options<CreatePizzaDayData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/pizzaDay/create',
...options
});
};
/**
* Smazání pizza day.
*/
export const deletePizzaDay = <ThrowOnError extends boolean = false>(options?: Options<DeletePizzaDayData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/pizzaDay/delete',
...options
});
};
/**
* Uzamkne pizza day. Nebude možné přidávat či odebírat pizzy.
*/
export const lockPizzaDay = <ThrowOnError extends boolean = false>(options?: Options<LockPizzaDayData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/pizzaDay/lock',
...options
});
};
/**
* Odemkne pizza day. Bude opět možné přidávat či odebírat pizzy.
*/
export const unlockPizzaDay = <ThrowOnError extends boolean = false>(options?: Options<UnlockPizzaDayData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/pizzaDay/unlock',
...options
});
};
/**
* Přepnutí pizza day do stavu "Pizzy objednány". Není možné měnit objednávky, příslušným uživatelům je odeslána notifikace o provedené objednávce.
*/
export const finishOrder = <ThrowOnError extends boolean = false>(options?: Options<FinishOrderData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/pizzaDay/finishOrder',
...options
});
};
/**
* Převod pizza day do stavu "Pizzy byly doručeny". Pokud má objednávající nastaveno číslo účtu, je ostatním uživatelům vygenerován a následně zobrazen QR kód pro úhradu jejich objednávky.
*/
export const finishDelivery = <ThrowOnError extends boolean = false>(options: Options<FinishDeliveryData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/pizzaDay/finishDelivery',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Přidání pizzy do objednávky.
*/
export const addPizza = <ThrowOnError extends boolean = false>(options: Options<AddPizzaData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/pizzaDay/add',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Odstranění pizzy z objednávky.
*/
export const removePizza = <ThrowOnError extends boolean = false>(options: Options<RemovePizzaData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/pizzaDay/remove',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Nastavení poznámky k objednávkám pizz přihlášeného uživatele.
*/
export const updatePizzaDayNote = <ThrowOnError extends boolean = false>(options: Options<UpdatePizzaDayNoteData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/pizzaDay/updatePizzaDayNote',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Nastavení přirážky/slevy k objednávce pizz uživatele.
*/
export const updatePizzaFee = <ThrowOnError extends boolean = false>(options: Options<UpdatePizzaFeeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/pizzaDay/updatePizzaFee',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Vrátí náhodně metadata jednoho z definovaných easter egg obrázků pro přihlášeného uživatele, nebo nic, pokud žádné definované nemá.
*/
export const getEasterEgg = <ThrowOnError extends boolean = false>(options?: Options<GetEasterEggData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetEasterEggResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/easterEggs',
...options
});
};
/**
* Vrátí obrázek konkrétního easter eggu
*/
export const getEasterEggImage = <ThrowOnError extends boolean = false>(options: Options<GetEasterEggImageData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<GetEasterEggImageResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/easterEggs/{url}',
...options
});
};
/**
* Vrátí statistiky způsobu stravování pro předaný rozsah dat.
*/
export const getStats = <ThrowOnError extends boolean = false>(options: Options<GetStatsData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<GetStatsResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/stats',
...options
});
};
/**
* Vrátí statistiky hlasování o nových funkcích.
*/
export const getVotes = <ThrowOnError extends boolean = false>(options?: Options<GetVotesData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetVotesResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/voting/getVotes',
...options
});
};
/**
* Aktualizuje hlasování uživatele o dané funkcionalitě.
*/
export const updateVote = <ThrowOnError extends boolean = false>(options: Options<UpdateVoteData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/voting/updateVote',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Vrátí agregované statistiky hlasování o nových funkcích.
*/
export const getVotingStats = <ThrowOnError extends boolean = false>(options?: Options<GetVotingStatsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetVotingStatsResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/voting/stats',
...options
});
};
/**
* Vrátí nastavení notifikací přihlášeného uživatele.
*/
export const getNotificationSettings = <ThrowOnError extends boolean = false>(options?: Options<GetNotificationSettingsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetNotificationSettingsResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/notifications/settings',
...options
});
};
/**
* Uloží nastavení notifikací přihlášeného uživatele.
*/
export const updateNotificationSettings = <ThrowOnError extends boolean = false>(options: Options<UpdateNotificationSettingsData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<GetNotificationSettingsResponse, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/notifications/settings',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Označí QR kód jako uhrazený (zaplacený).
*/
export const dismissQr = <ThrowOnError extends boolean = false>(options: Options<DismissQrData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/pizzaDay/dismissQr',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};

1030
types/gen/types.gen.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
get:
operationId: getNotificationSettings
summary: Vrátí nastavení notifikací pro přihlášeného uživatele.
responses:
"200":
description: Nastavení notifikací
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/NotificationSettings"
post:
operationId: updateNotificationSettings
summary: Uloží nastavení notifikací pro přihlášeného uživatele.
requestBody:
required: true
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/NotificationSettings"
responses:
"200":
description: Nastavení notifikací bylo uloženo.
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/NotificationSettings"

View File

@@ -1,17 +0,0 @@
post:
operationId: dismissQr
summary: Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených).
requestBody:
required: true
content:
application/json:
schema:
properties:
date:
description: Datum Pizza day, ke kterému se QR kód vztahuje
type: string
required:
- date
responses:
"200":
description: QR kód byl označen jako uhrazený.

View File

@@ -53,11 +53,6 @@ ClientData:
description: Datum a čas poslední aktualizace pizz
type: string
format: date-time
pendingQrs:
description: Nevyřízené QR kódy pro platbu z předchozích pizza day
type: array
items:
$ref: "#/PendingQr"
# --- OBĚDY ---
UserLunchChoice:
@@ -532,24 +527,6 @@ NotifikaceData:
type: boolean
ntfy:
type: boolean
NotificationSettings:
description: Nastavení notifikací pro konkrétního uživatele
type: object
properties:
ntfyTopic:
description: Téma pro ntfy push notifikace
type: string
discordWebhookUrl:
description: URL webhooku Discord kanálu
type: string
teamsWebhookUrl:
description: URL webhooku MS Teams kanálu
type: string
enabledEvents:
description: Seznam událostí, o kterých chce být uživatel notifikován
type: array
items:
$ref: "#/UdalostEnum"
GotifyServer:
type: object
required:
@@ -562,23 +539,3 @@ GotifyServer:
type: array
items:
type: string
# --- NEVYŘÍZENÉ QR KÓDY ---
PendingQr:
description: Nevyřízený QR kód pro platbu z předchozího Pizza day
type: object
additionalProperties: false
required:
- date
- creator
- totalPrice
properties:
date:
description: Datum Pizza day, ke kterému se QR kód vztahuje
type: string
creator:
description: Jméno zakladatele Pizza day (objednávajícího)
type: string
totalPrice:
description: Celková cena objednávky v Kč
type: number