generated from partypages/party-template
Implement admin-ui prototype
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
9e80ab2b41
commit
c31a6632b4
16691
package-lock.json
generated
16691
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
184
src/AdminUI.css
Normal file
184
src/AdminUI.css
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.content {
|
||||||
|
padding: 5px !important;
|
||||||
|
}
|
||||||
|
span.lg {
|
||||||
|
font-size: 12pt!important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
/*background-color: grey;*/
|
||||||
|
padding: 100px 50px;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas: "hd-1 hd-2 hd-3" "body body body";
|
||||||
|
grid-template-columns: auto 10fr auto;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.content > div {
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
background-color: lightgray;
|
||||||
|
}
|
||||||
|
.content > div > span {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.create-party-label {
|
||||||
|
grid-area: hd-3;
|
||||||
|
font-size: 16pt;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: darkgray!important ;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-party-label {
|
||||||
|
grid-area: hd-1;
|
||||||
|
font-size: 16pt;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-party-box {
|
||||||
|
overflow: scroll;
|
||||||
|
grid-area: hd-2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.select-party {
|
||||||
|
background-color: darkgray;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 16pt;
|
||||||
|
}
|
||||||
|
.select-party.selected {
|
||||||
|
background-color: rgb(255, 189, 67);
|
||||||
|
}
|
||||||
|
.select-party > span {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-box {
|
||||||
|
grid-area: body;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px !important;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guests {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
gap: 5px 0px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.guests .header {
|
||||||
|
background-color: unset!important;
|
||||||
|
justify-self: center;
|
||||||
|
justify-content: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.confirmations > span:not(:last-child) {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
.confirmations > span:nth-child(1) {
|
||||||
|
color: green;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.confirmations > span:nth-child(2) {
|
||||||
|
color: red;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.confirmations > span:nth-child(3) {
|
||||||
|
color: black;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
span.lg {
|
||||||
|
font-size: 18pt;
|
||||||
|
}
|
||||||
|
.guests > div {
|
||||||
|
background-color: lightgrey!important ;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.guests>div:nth-child(8n+5),
|
||||||
|
.guests>div:nth-child(8n+6),
|
||||||
|
.guests>div:nth-child(8n+7),
|
||||||
|
.guests>div:nth-child(8n+8) {
|
||||||
|
background-color: #c5c5c5!important;
|
||||||
|
}
|
||||||
|
.guests>div:nth-child(8n+9),
|
||||||
|
.guests>div:nth-child(8n+10),
|
||||||
|
.guests>div:nth-child(8n+11),
|
||||||
|
.guests>div:nth-child(8n+12) {
|
||||||
|
background-color: #dedede!important;
|
||||||
|
;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.partyname {
|
||||||
|
font-size: 24pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog>div{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
dialog > div label {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
#backdrop {
|
||||||
|
background-color: black;
|
||||||
|
opacity: 0.3;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
left:0px;
|
||||||
|
}
|
||||||
|
.dialog-table {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.add-guest-button {
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: darkgray;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.add-guest {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.add-guest > .tip {
|
||||||
|
align-self: flex-end;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
.dialog-bottom {
|
||||||
|
display:flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-around;
|
||||||
|
width:100%;
|
||||||
|
}
|
181
src/AdminUI.tsx
Normal file
181
src/AdminUI.tsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
|
||||||
|
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import './AdminUI.css';
|
||||||
|
import { createGuestRequest, deleteGuestRequest, listGuestsRequest, listPartyRequest, modifyGuestRequest, parseToken } from './partyAdminApi';
|
||||||
|
import { GrammaticalGender, Person, RequestCreateGuest, ResponseCreateParty, ResponseListGuests, ResponseListParties } from './partyAdminApiTypes';
|
||||||
|
|
||||||
|
export const AdminUI: React.FC = () => {
|
||||||
|
|
||||||
|
const [partyList, setPartyList] = useState<ResponseListParties>([]);
|
||||||
|
const [selectedParty, setSelectedParty] = useState<number | undefined>(undefined);
|
||||||
|
// eslint-disable-next-line no-restricted-globals
|
||||||
|
const adminToken = useMemo(() => parseToken(location.href) ?? "666c7199d0d1a3a90c5b10cf6fff364eb04eeffa8c76c82541a55213338ce983", []);
|
||||||
|
|
||||||
|
const loadPartyList = useCallback(async () => {
|
||||||
|
const response = await listPartyRequest(adminToken);
|
||||||
|
if (selectedParty === undefined)
|
||||||
|
setSelectedParty(response.length > 0 ? 0 : undefined);
|
||||||
|
setPartyList(response);
|
||||||
|
}, [adminToken, selectedParty]);
|
||||||
|
|
||||||
|
const deleteUser = async () => {
|
||||||
|
if (selectedParty === undefined || !partyList[selectedParty]) return;
|
||||||
|
if (!editUserData || (!("_id" in editUserData))) return;
|
||||||
|
deleteGuestRequest(adminToken, partyList[selectedParty].name, editUserData);
|
||||||
|
loadPartyList();
|
||||||
|
dismissBackdrop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const editUser = (user?: RequestCreateGuest) => {
|
||||||
|
const dialog = document.getElementById("dialog-edit-user") as HTMLDialogElement;
|
||||||
|
if (!dialog) return;
|
||||||
|
setEditUserData({ coming: null, extra: {}, grammatical_gender: "d", name: "", token: "", ...user });
|
||||||
|
dialog.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
const [editUserData, setEditUserData] = useState<RequestCreateGuest|Person>();
|
||||||
|
const dismissBackdrop = () => {
|
||||||
|
const dialog = document.getElementById("dialog-edit-user") as HTMLDialogElement;
|
||||||
|
setEditUserData(undefined);
|
||||||
|
dialog.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEditUser = () => {
|
||||||
|
if (selectedParty === undefined || !partyList[selectedParty]) return;
|
||||||
|
if (!editUserData) return;
|
||||||
|
if ("_id" in editUserData) {
|
||||||
|
modifyGuestRequest(adminToken, partyList[selectedParty].name, editUserData);
|
||||||
|
} else {
|
||||||
|
createGuestRequest(adminToken, partyList[selectedParty].name, editUserData);
|
||||||
|
}
|
||||||
|
loadPartyList();
|
||||||
|
dismissBackdrop();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (adminToken === "") return;
|
||||||
|
loadPartyList();
|
||||||
|
}, [adminToken, loadPartyList]);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{editUserData ?
|
||||||
|
<div id="backdrop" onClick={dismissBackdrop} />
|
||||||
|
: null}
|
||||||
|
<dialog id="dialog-edit-user">
|
||||||
|
<div>
|
||||||
|
{editUserData ?
|
||||||
|
<>
|
||||||
|
<span className="lg">{"_id" in editUserData ? `Editing ${editUserData.name}`: "New Invitation"}</span>
|
||||||
|
<div className="dialog-table">
|
||||||
|
<label htmlFor="edit-token">Token</label>
|
||||||
|
<input type="text" id="edit-token" value={editUserData.token} onChange={(e) => { setEditUserData({ ...editUserData, token: e.target.value }) }} />
|
||||||
|
<label htmlFor="edit-name">Name</label>
|
||||||
|
<input type="text" id="edit-name" value={editUserData.name} onChange={(e) => { setEditUserData({ ...editUserData, name: e.target.value }) }} />
|
||||||
|
<label htmlFor="edit-coming">Coming</label>
|
||||||
|
<select id="edit-coming" value={editUserData.coming ?? "null"} onChange={(e) => {
|
||||||
|
const choice = e.target.value as "yes" | "no" | "maybe" | "null";
|
||||||
|
setEditUserData({ ...editUserData, coming: (choice === "null" ? null : choice) });
|
||||||
|
}} >
|
||||||
|
<option value="yes">yes</option>
|
||||||
|
<option value="no">no</option>
|
||||||
|
<option value="maybe">maybe</option>
|
||||||
|
<option value="null"></option>
|
||||||
|
</select>
|
||||||
|
<label htmlFor="edit-gender">Gender</label>
|
||||||
|
<select id="edit-gender" value={editUserData.grammatical_gender} onChange={(e) => {
|
||||||
|
const choice = e.target.value as GrammaticalGender
|
||||||
|
setEditUserData({ ...editUserData, grammatical_gender: choice });
|
||||||
|
}} >
|
||||||
|
<option value="m">m</option>
|
||||||
|
<option value="f">f</option>
|
||||||
|
<option value="d">d</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
: "Hab keine user data bekommen."}
|
||||||
|
|
||||||
|
<div className="dialog-bottom">
|
||||||
|
<button onClick={() => { deleteUser() }}>Delete</button>
|
||||||
|
<button onClick={() => { saveEditUser() }}>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<div className="content">
|
||||||
|
<div className="select-party-label">
|
||||||
|
<span>
|
||||||
|
Select a party
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="select-party-box">
|
||||||
|
{partyList.map((p, index) =>
|
||||||
|
<div className={`select-party ${index === selectedParty ? "selected" : ""}`}>
|
||||||
|
<span onClick={() => setSelectedParty(index)} key={index}>{p.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div title="Create New Party" className="create-party-label">
|
||||||
|
<span>
|
||||||
|
🎉
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div className="party-box">
|
||||||
|
{selectedParty !== undefined ?
|
||||||
|
<PartyUI party={partyList[selectedParty]} adminToken={adminToken} editUser={editUser} />
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PartyUI: React.FC<{ party: ResponseCreateParty, adminToken: string, editUser: (user: RequestCreateGuest) => void }> = ({ party, adminToken, editUser }) => {
|
||||||
|
const [guests, setGuests] = useState<ResponseListGuests>([]);
|
||||||
|
|
||||||
|
const loadGuests = useCallback(async () => {
|
||||||
|
const guests = await listGuestsRequest(adminToken, party.name);
|
||||||
|
setGuests(guests);
|
||||||
|
}, [adminToken, party.name]);
|
||||||
|
|
||||||
|
const confirmations = useMemo<[number, number, number]>(() => [
|
||||||
|
guests.filter(g => g.coming === "yes").length,
|
||||||
|
guests.filter(g => g.coming === "no").length,
|
||||||
|
guests.filter(g => g.coming === null || g.coming === "maybe").length,
|
||||||
|
], [guests]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGuests();
|
||||||
|
}, [loadGuests, party]);
|
||||||
|
|
||||||
|
const exportPartyLink = async (user: RequestCreateGuest) => {
|
||||||
|
const url = `https://${party.name}.party.leafbla.de/${user.token}`;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div>
|
||||||
|
<div className="guests">
|
||||||
|
<div className="header"><span className="lg">Token </span></div>
|
||||||
|
<div className="header"><span className="lg">Name </span></div>
|
||||||
|
<div className="header"><span className="lg">Gender</span></div>
|
||||||
|
<div className="header"><span className="lg">Coming</span><div className="confirmations">
|
||||||
|
<span>{confirmations[0]}</span>
|
||||||
|
<span>{confirmations[1]}</span>
|
||||||
|
<span>{confirmations[2]}</span>
|
||||||
|
</div></div>
|
||||||
|
{guests.map((guest, index) => <Fragment key={index}>
|
||||||
|
<div onContextMenu={(e) => { e.preventDefault(); exportPartyLink(guest); }} onClick={() => editUser(guest)}>{guest.token}</div>
|
||||||
|
<div onContextMenu={(e) => { e.preventDefault(); exportPartyLink(guest); }} onClick={() => editUser(guest)}>{guest.name}</div>
|
||||||
|
<div onContextMenu={(e) => { e.preventDefault(); exportPartyLink(guest); }} onClick={() => editUser(guest)}>{guest.grammatical_gender}</div>
|
||||||
|
<div onContextMenu={(e) => { e.preventDefault(); exportPartyLink(guest); }} onClick={() => editUser(guest)}>{guest.coming ?? "?"}</div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="add-guest">
|
||||||
|
<span className="tip">Did you know: Contextmenu copies the invite link!</span>
|
||||||
|
<span className="lg add-guest-button" onClick={() => editUser({ coming: null, extra: {}, grammatical_gender: "m", name: "", token: "" })}>
|
||||||
|
🏃
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
};
|
@ -10,4 +10,4 @@ body {
|
|||||||
code {
|
code {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
@ -1,19 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { AdminUI } from './AdminUI';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
|
||||||
import reportWebVitals from './reportWebVitals';
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById('root') as HTMLElement
|
document.getElementById('root') as HTMLElement
|
||||||
);
|
);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<AdminUI/>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
// If you want to start measuring performance in your app, pass a function
|
|
||||||
// to log results (for example: reportWebVitals(console.log))
|
|
||||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
|
||||||
reportWebVitals();
|
|
55
src/partyAdminApi.ts
Normal file
55
src/partyAdminApi.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Person, RequestCreateGuest, RequestCreateParty, ResponseCreateParty, ResponseListGuests, ResponseListParties } from "./partyAdminApiTypes";
|
||||||
|
|
||||||
|
export const parseToken = (uri: string): string | undefined => {
|
||||||
|
const x = uri.match(/https?:\/\/party\.leafbla\.de\/(?<token>.+)/);
|
||||||
|
if (x === null || x.groups === undefined) return;
|
||||||
|
const token = x.groups["token"];
|
||||||
|
if (!token) return;
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiUrl = (adminToken : string): string => {
|
||||||
|
return `https://party.leafbla.de/api/${adminToken}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listPartyRequest = async (adminToken: string): Promise<ResponseListParties> => {
|
||||||
|
const result = await fetch(`${apiUrl(adminToken)}`, { method: "get" });
|
||||||
|
if (!result.ok) throw new Error("Error sending listPartyRequest");
|
||||||
|
const data = await result.json();
|
||||||
|
return data as ResponseListParties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPartyRequest = async (adminToken: string, payload: RequestCreateParty): Promise<ResponseCreateParty> => {
|
||||||
|
const result = await fetch(`${apiUrl(adminToken)}`, { method: "post", body: JSON.stringify(payload) });
|
||||||
|
if (!result.ok) throw new Error("Error sending createPartyRequest");
|
||||||
|
const data = await result.json();
|
||||||
|
return data as ResponseCreateParty;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listGuestsRequest = async (adminToken: string, partyName: string): Promise<ResponseListGuests> => {
|
||||||
|
const result = await fetch(`${apiUrl(adminToken)}/${partyName}`, { method: "get" });
|
||||||
|
if (!result.ok) throw new Error("Error sending listGuestRequest");
|
||||||
|
const data = await result.json();
|
||||||
|
return data as ResponseListGuests;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createGuestRequest = async (adminToken: string, partyName: string, payload: RequestCreateGuest): Promise<Person> => {
|
||||||
|
const result = await fetch(`${apiUrl(adminToken)}/${partyName}`, { method: "post", body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } });
|
||||||
|
if (!result.ok) throw new Error("Error sending createGuestRequest");
|
||||||
|
const data = await result.json();
|
||||||
|
return data as Person;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const modifyGuestRequest = async (adminToken: string, partyName: string, payload: Person): Promise<Person> => {
|
||||||
|
const result = await fetch(`${apiUrl(adminToken)}/${partyName}/${payload._id}`, { method: "PATCH", body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } });
|
||||||
|
if (!result.ok) throw new Error("Error sending createGuestRequest");
|
||||||
|
const data = await result.json();
|
||||||
|
return data as Person;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteGuestRequest = async (adminToken: string, partyName: string, payload: Person): Promise<Person> => {
|
||||||
|
const result = await fetch(`${apiUrl(adminToken)}/${partyName}/${payload._id}`, { method: "DELETE", headers: { "Content-Type": "application/json" } });
|
||||||
|
if (!result.ok) throw new Error("Error sending createGuestRequest");
|
||||||
|
const data = await result.json();
|
||||||
|
return data as Person;
|
||||||
|
};
|
37
src/partyAdminApiTypes.ts
Normal file
37
src/partyAdminApiTypes.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
export type ResponseListParties = ResponseCreateParty[];
|
||||||
|
|
||||||
|
export type ResponseCreateParty = {
|
||||||
|
"_id": string,
|
||||||
|
name: string,
|
||||||
|
created: string,
|
||||||
|
allowedExtra: AllowedExtraLengths,
|
||||||
|
}
|
||||||
|
export type AllowedExtraLengths = { [key: string]: number };
|
||||||
|
|
||||||
|
export type RequestCreateParty = {
|
||||||
|
name: "string",
|
||||||
|
"allowed_extra": AllowedExtraLengths,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResponseListGuests = Person[];
|
||||||
|
|
||||||
|
|
||||||
|
export type GrammaticalGender = "m" | "f" | "d";
|
||||||
|
export type ComingStatus = "yes" | "no" | "maybe" | null;
|
||||||
|
|
||||||
|
export type Person = {
|
||||||
|
"_id": string,
|
||||||
|
token: string,
|
||||||
|
name: string,
|
||||||
|
coming: ComingStatus,
|
||||||
|
"grammatical_gender": GrammaticalGender,
|
||||||
|
extra: Record<string, string>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RequestCreateGuest = {
|
||||||
|
token: string,
|
||||||
|
name: string,
|
||||||
|
coming: ComingStatus,
|
||||||
|
"grammatical_gender": GrammaticalGender,
|
||||||
|
extra: Record<string, string>,
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user