This commit is contained in:
monoid 2024-05-03 00:23:48 +09:00
commit 161a813b67
53 changed files with 7903 additions and 0 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
DB_PATH=order.sqlite3
SERVER_PORT=3000

27
.eslintrc.cjs Normal file
View File

@ -0,0 +1,27 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
],
ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
rules: {
"react-refresh/only-export-components": [
"off",
],
"unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
},
],
},
};

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sqlite3

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 ABDELLATIF LAGHJAJ
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

34
README.md Normal file
View File

@ -0,0 +1,34 @@
# Simple Kiosk App
This is a simple kiosk app that displays a list of items and allows the user to select an item to view more details.
It is using React + TypeScript + Tailwind CSS + Vite.
## Features
- Display a list of items
- Order items
- Menu CRUD
- Order Request CRUD
- Order Request Status Update
## Getting Started
To get started, clone the repository and run the following commands:
```bash
pnpm install
pnpm run dev
```
This will start the development server.
## Building the App
To build the app, run the following command:
```bash
pnpm build
```
This will create a `dist` folder with the built app.

17
components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/client/index.css",
"baseColor": "gray",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

20
dprint.json Normal file
View File

@ -0,0 +1,20 @@
{
"typescript": {
},
"json": {
},
"markdown": {
},
"toml": {
},
"excludes": [
"**/node_modules",
"**/*-lock.json"
],
"plugins": [
"https://plugins.dprint.dev/typescript-0.90.4.wasm",
"https://plugins.dprint.dev/json-0.19.2.wasm",
"https://plugins.dprint.dev/markdown-0.17.0.wasm",
"https://plugins.dprint.dev/toml-0.6.1.wasm"
]
}

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100..900&display=swap" rel="stylesheet">
<title>Kiosk</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

41
lefthook.yml Normal file
View File

@ -0,0 +1,41 @@
# EXAMPLE USAGE:
#
# Refer for explanation to following link:
# https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md
#
# pre-push:
# commands:
# packages-audit:
# tags: frontend security
# run: yarn audit
# gems-audit:
# tags: backend security
# run: bundle audit
#
pre-commit:
parallel: true
commands:
format:
tags: style
glob: "*.{js,ts,jsx,tsx,css,json}"
run: pnpm run format {staged_files}
stage_fixed: true
eslint:
tags: lint
glob: "*.{js,ts,jsx,tsx}"
run: pnpm run lint {staged_files}
# rubocop:
# tags: backend style
# glob: "*.rb"
# exclude: '(^|/)(application|routes)\.rb$'
# run: bundle exec rubocop --force-exclusion {all_files}
# govet:
# tags: backend style
# files: git ls-files -m
# glob: "*.go"
# run: go vet {files}
# scripts:
# "hello.js":
# runner: node
# "any.go":
# runner: go run

62
package.json Normal file
View File

@ -0,0 +1,62 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "nodemon -w src/server -x tsx src/server/main.ts",
"start": "cross-env NODE_ENV=production tsx src/server/main.ts",
"build": "vite build",
"format": "dprint fmt --allow-no-files",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"shadcn-ui": "shadcn-ui"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@tanstack/react-query": "^5.32.1",
"axios": "^1.6.8",
"better-sqlite3": "^9.6.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"kysely": "^0.27.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.3.0",
"typescript": "^5.3.2",
"vaul": "^0.9.0",
"vite-express": "*",
"wouter": "^3.1.2",
"zod": "^3.23.5"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.10",
"@types/express": "^4.17.21",
"@types/node": "^20.9.3",
"@types/react": "^18.0.38",
"@types/react-dom": "^18.2.16",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.19",
"dprint": "^0.45.1",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.6",
"lefthook": "^1.6.10",
"nodemon": "^3.0.1",
"postcss": "^8.4.38",
"shadcn-ui": "^0.8.0",
"tailwindcss": "^3.4.3",
"vite": "^5.0.2"
}
}

5300
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

14
src/client/App.css Normal file
View File

@ -0,0 +1,14 @@
html, body {
padding: 0;
margin: 0;
}
body {
min-height: 100dvh;
background-color: var(hsl(--background));
font-family: "Noto Sans KR", sans-serif;
font-optical-sizing: auto;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

32
src/client/App.tsx Normal file
View File

@ -0,0 +1,32 @@
import { Route, Switch } from "wouter";
import "./App.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NavMenu from "./components/nav-menu";
import Home from "./pages/Home";
import MenuPage from "./pages/Menu";
import NotFound from "./pages/NotFound";
import Order from "./pages/Order";
import Stat from "./pages/Stat";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<div className="min-h-dvh">
<NavMenu />
<main className="h-[calc(100dvh-4rem)]">
<Switch>
<Route path="/" component={Home} />
<Route path="/order" component={Order} />
<Route path="/menu" component={MenuPage} />
<Route path="/stat" component={Stat} />
<Route component={NotFound} />
</Switch>
</main>
</div>
</QueryClientProvider>
);
}
export default App;

19
src/client/api/menu.ts Normal file
View File

@ -0,0 +1,19 @@
import axios from "axios";
import { MenuItemColumns } from "src/db";
import { AddNewMenuItemType, UpdateMenuItemType } from "src/schema";
export async function getListMenuItems() {
return (await axios.get<MenuItemColumns[]>("/api/menu")).data;
}
export async function addNewMenuItem(name: string, item: AddNewMenuItemType) {
return await axios.post(`/api/menu/${encodeURIComponent(name)}`, item);
}
export async function updateMenuItem(name: string, item: UpdateMenuItemType) {
return await axios.patch(`/api/menu/${encodeURIComponent(name)}`, item);
}
export async function deleteMenuItem(name: string) {
return await axios.delete(`/api/menu/${encodeURIComponent(name)}`);
}

39
src/client/api/order.ts Normal file
View File

@ -0,0 +1,39 @@
import axios from "axios";
import { OrderStateColumns } from "src/db";
import { AddNewOrderType } from "src/schema";
export async function loadOrder() {
return (await axios.get<OrderStateColumns[]>("/api/order")).data;
}
export async function loadOrderById(id: string) {
return (await axios.get<OrderStateColumns>(`/api/order/${encodeURIComponent(id)}`)).data;
}
export async function addNewOrder(order: AddNewOrderType) {
return await axios.post("/api/order", order);
}
export async function cancelOrder(id: string) {
return await axios.delete(`/api/order/${encodeURIComponent(id)}`);
}
export async function completeOrder(id: string) {
return await axios.post(`/api/order/${encodeURIComponent(id)}/complete`);
}
export async function cancelCompleteOrder(id: string) {
return await axios.post(`/api/order/${encodeURIComponent(id)}/cancel-complete`);
}
export async function clearOrder() {
return await axios.delete("/api/order");
}
export function notifyOrderChange(callback: () => void) {
const eventSource = new EventSource("/api/order/notify");
eventSource.onmessage = callback;
return () => {
eventSource.close();
};
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,112 @@
import { OrderAction } from "@/hooks/useOrder";
import { cn } from "@/lib/utils";
import { Dispatch } from "react";
import { MenuItem } from "src/db";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
export default function Menu({
menus,
categories,
dispatch,
}: {
menus: MenuItem[];
categories: string[];
dispatch: Dispatch<OrderAction>;
}) {
const menu = menus;
return (
<Tabs defaultValue={categories[0]}>
<TabsList>
{[...categories].map(category => <TabsTrigger key={category} value={category}>{category}</TabsTrigger>)}
</TabsList>
{[...categories].map(category => (
<TabsContent key={category} value={category}>
<Card>
<CardHeader>
<CardTitle>{category}</CardTitle>
</CardHeader>
<CardContent>
<ul className="w-full flex flex-col">
{menu.filter(item => item.category === category).map((item, index) => (
<li
key={item.name}
className={cn(
"flex flex-col gap-2 sm:flex-wrap sm:justify-between sm:flex-row",
index % 2 === 0 ? "bg-accent" : "",
)}
>
<span className="">{item.name}</span>
<div className="flex gap-2 justify-between">
{item.price != null
? (
<Button
variant="outline"
className=""
onClick={() => {
dispatch({
type: "ADD_ORDER",
order: {
name: item.name,
HOT: 0,
price: item.price ?? 0,
quantity: 1,
},
});
}}
>
{item.price * 1000}
</Button>
)
: (
<>
<Button
variant="outline"
className="bg-sky-400 hover:bg-sky-500"
disabled={!item.COLD}
onClick={() => {
dispatch({
type: "ADD_ORDER",
order: {
name: item.name,
HOT: 1,
price: item.COLD ?? 0,
quantity: 1,
},
});
}}
>
{(item.COLD ?? 0) * 1000} ICE
</Button>
<Button
variant="outline"
className="bg-red-400 hover:bg-red-500"
disabled={!item.HOT}
onClick={() => {
dispatch({
type: "ADD_ORDER",
order: {
name: item.name,
HOT: 1,
price: item.HOT ?? 0,
quantity: 1,
},
});
}}
>
{(item.HOT ?? 0) * 1000} HOT
</Button>
</>
)}
</div>
</li>
))}
</ul>
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
);
}

View File

@ -0,0 +1,65 @@
import { EnterFullScreenIcon, ExitFullScreenIcon } from "@radix-ui/react-icons";
import { Link } from "wouter";
import { Button } from "./ui/button";
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from "./ui/navigation-menu";
export default function NavMenu() {
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 max-w-screen-xl items-center justify-between">
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/">
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/order">
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/menu">
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/stat">
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<div>
<Button
variant="ghost"
onClick={() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
}}
>
{document.fullscreenElement ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
</Button>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,85 @@
import { useCancelOrder, useCompleteOrder } from "@/hooks/useOrders";
import { TrashIcon } from "@radix-ui/react-icons";
import { useQueryClient } from "@tanstack/react-query";
import { OrderStateColumns } from "src/db";
import { Button } from "./ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./ui/card";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
export default function OrderCard({
order,
}: {
order: OrderStateColumns;
}) {
const queryClient = useQueryClient();
const { mutate: mutateCompleteOrder, isPending: isCompleteOrderPending } = useCompleteOrder(queryClient);
const { mutate: mutateCancelOrder, isPending: isCancelOrderPending } = useCancelOrder(queryClient);
return (
<Card className="flex flex-col h-60">
<CardHeader className="relative">
<div className="absolute right-2">
<Drawer>
<DrawerTrigger asChild>
<Button variant="ghost" disabled={order.completed == 1 || isCancelOrderPending}>
<TrashIcon />
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>
</DrawerTitle>
<DrawerDescription>
?
</DrawerDescription>
</DrawerHeader>
<DrawerClose asChild>
<Button
onClick={() => {
mutateCancelOrder(order.id);
}}
>
</Button>
</DrawerClose>
</DrawerContent>
</Drawer>
</div>
<CardTitle className={order.completed == 1 ? "text-muted-foreground" : ""}>
{order.id.split("/")[1]}
</CardTitle>
<CardDescription>{order.completed ? "완료" : "대기중..."}</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-auto">
<hr />
{order.orders.map((item) => (
<div key={item.name}>
<span>{item.HOT ? "HOT" : "COLD"} {item.name} {item.quantity}</span>
</div>
))}
</CardContent>
<CardFooter>
<Button
disabled={isCompleteOrderPending}
onClick={() => {
mutateCompleteOrder({
completed: order.completed ? 0 : 1,
id: order.id,
});
}}
>
{order.completed == 0 ? "완료" : "완료 취소"}
</Button>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,159 @@
"use client";
import { useAddOrder } from "@/hooks/useOrders";
import { useQueryClient } from "@tanstack/react-query";
import { MenuItem } from "src/db";
import { useLocation } from "wouter";
import { useOrder } from "../hooks/useOrder";
import Menu from "./menu";
import { Button } from "./ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
export default function Order({
menus,
categories,
}: {
menus: MenuItem[];
categories: string[];
}) {
const queryClient = useQueryClient();
const { state, dispatch } = useOrder();
const { mutate, isPending } = useAddOrder(queryClient);
const [, navigate] = useLocation();
return (
<>
<Menu menus={menus} categories={categories} dispatch={dispatch} />
<Drawer>
<DrawerTrigger asChild>
<Button className="w-full mt-2 flex items-center">
Order{" "}
<span className="
ml-1 flex items-center justify-center rounded-full h-6 px-1 text-center bg-background text-foreground">
{state.orders.reduce(
(acc, order) => acc + order.quantity,
0,
)}
</span>
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle> ?</DrawerTitle>
</DrawerHeader>
<hr className="mx-4" />
<ul className="flex flex-col gap-2 justify-center m-4">
{state.orders.map(order => (
<li key={`${order.HOT ? "HOT" : "COLD"} ${order.name}`} className="flex items-center justify-between">
<div className="flex flex-wrap gap-2">
<div className="space-x-2 font-bold">
<span className="w-12">{order.HOT ? "HOT " : "COLD"}</span>
<span className="flex-1 break-words">{order.name}</span>
</div>
<div className="flex flex-wrap gap-x-2">
<div className="space-x-2">
<span className="flex-1 break-words">{order.price * 1000}</span>
<span className="flex-1">{order.quantity}</span>
</div>
<span className="flex-1 text-nowrap"> {order.price * order.quantity * 1000}</span>
</div>
</div>
<div className="space-x-2 flex">
<Button
variant="outline"
className="rounded-full"
onClick={() =>
dispatch({
type: "ADD_ORDER",
order: {
...order,
quantity: 1,
},
})}
>
+1
</Button>
<Button
variant="outline"
className="rounded-full"
onClick={() =>
dispatch({
type: "ADD_ORDER",
order: {
...order,
quantity: -1,
},
})}
>
-1
</Button>
<Button
variant="outline"
onClick={() =>
dispatch({ type: "REMOVE_ORDER", order })}
>
</Button>
</div>
</li>
))}
</ul>
<hr className="mx-4" />
<div className="flex mx-4 flex-col items-end">
<span className="text-sm text-muted-foreground"> </span>
<span className="flex-1 text-lg">
{state.orders.map(order => order.price * order.quantity)
.reduce((acc, price) => acc + price, 0) * 1000}
</span>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button
disabled={isPending}
onClick={async () => {
console.log("menu order start");
mutate({
orders: state.orders,
payment: "account",
}, {
onSuccess: () => {
navigate("/");
},
});
}}
>
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button
disabled={isPending}
onClick={async () => {
console.log("cash order start");
mutate({
orders: state.orders,
payment: "cash",
}, {
onSuccess: () => {
navigate("/");
},
});
}}
>
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
);
}

View File

@ -0,0 +1,53 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants>
{
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,74 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };

View File

@ -0,0 +1,116 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
};

View File

@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,126 @@
import { ChevronDownIcon } from "@radix-ui/react-icons";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className,
)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenuViewport,
};

View File

@ -0,0 +1,53 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsContent, TabsList, TabsTrigger };

View File

@ -0,0 +1,56 @@
import { addNewMenuItem, deleteMenuItem, getListMenuItems, updateMenuItem } from "@/api/menu";
import { QueryClient, useMutation, useQuery } from "@tanstack/react-query";
import { AddNewMenuItemType } from "src/schema";
export function useMenu() {
return useQuery({
queryKey: ["menu"],
queryFn: getListMenuItems,
});
}
export function useAddMenuItem(queryClient: QueryClient) {
return useMutation({
mutationKey: ["menu"],
mutationFn: ({
name,
...rest
}: {
name: string;
} & AddNewMenuItemType) => addNewMenuItem(name, rest),
onSuccess: (_data, _variables, _context) => {
queryClient.invalidateQueries({
queryKey: ["menu"],
});
},
}, queryClient);
}
export function useUpdateMenuItem(queryClient: QueryClient) {
return useMutation({
mutationKey: ["menu"],
mutationFn: ({
name,
...rest
}: {
name: string;
} & AddNewMenuItemType) => updateMenuItem(name, rest),
onSuccess: (_data, _variables, _context) => {
queryClient.invalidateQueries({
queryKey: ["menu"],
});
},
}, queryClient);
}
export function useDeleteMenuItem(queryClient: QueryClient) {
return useMutation({
mutationKey: ["menu"],
mutationFn: deleteMenuItem,
onSuccess: (_data, _variables, _context) => {
queryClient.invalidateQueries({
queryKey: ["menu"],
});
},
}, queryClient);
}

View File

@ -0,0 +1,67 @@
import { useReducer } from "react";
import { Order } from "../../db";
interface OrderState {
orders: Order[];
}
const initialState = {
orders: [],
} as OrderState;
export type OrderAction =
| { type: "ADD_ORDER"; order: Order }
| { type: "REMOVE_ORDER"; order: Order }
| { type: "UPDATE_ORDER"; order: Order };
function compareOrder(a: Order, b: Order) {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
if (a.HOT && !b.HOT) {
return -1;
}
if (!a.HOT && b.HOT) {
return 1;
}
return 0;
}
function reducer(state: OrderState, action: OrderAction): OrderState {
switch (action.type) {
case "ADD_ORDER":
if (state.orders.find(order => compareOrder(order, action.order) === 0)) {
return {
orders: state.orders.map(order =>
compareOrder(order, action.order) === 0
? {
...order,
quantity: Math.max(order.quantity + action.order.quantity, 0),
}
: order
),
};
}
return {
orders: [...state.orders, action.order],
};
case "REMOVE_ORDER":
return {
orders: state.orders.filter(order => compareOrder(order, action.order) !== 0),
};
case "UPDATE_ORDER":
return {
orders: state.orders.map(order => compareOrder(order, action.order) === 0 ? action.order : order),
};
default:
return state;
}
}
export function useOrder() {
const [state, dispatch] = useReducer(reducer, initialState);
return { state, dispatch };
}

View File

@ -0,0 +1,73 @@
import { QueryClient, useMutation, useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import {
addNewOrder,
cancelCompleteOrder,
cancelOrder,
completeOrder,
loadOrder,
notifyOrderChange,
} from "../api/order";
export function useOrders() {
return useQuery({
queryKey: ["orders"],
queryFn: loadOrder,
});
}
export function useAddOrder(queryClient: QueryClient) {
return useMutation({
mutationKey: ["orders"],
mutationFn: addNewOrder,
onSuccess: (_data, _variables, _context) => {
queryClient.invalidateQueries({
queryKey: ["orders"],
});
},
}, queryClient);
}
export function useCancelOrder(queryClient: QueryClient) {
return useMutation({
mutationKey: ["orders"],
mutationFn: cancelOrder,
onSuccess: (_data, _variables, _context) => {
queryClient.invalidateQueries({
queryKey: ["orders"],
});
},
}, queryClient);
}
export function useCompleteOrder(queryClient: QueryClient) {
return useMutation({
mutationKey: ["orders"],
mutationFn: ({
id,
completed,
}: {
id: string;
completed: 0 | 1;
}) => completed ? completeOrder(id) : cancelCompleteOrder(id),
onSuccess: (_data, _variables, _context) => {
queryClient.invalidateQueries({
queryKey: ["orders"],
});
},
}, queryClient);
}
export function useNotifyOrderChange(callback: () => void) {
useEffect(() => {
return notifyOrderChange(callback);
}, [callback]);
}
export function useOrderAutoRefresh(queryClient: QueryClient) {
useNotifyOrderChange(() => {
queryClient.invalidateQueries({
queryKey: ["orders"],
});
});
}

78
src/client/index.css Normal file
View File

@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 0.5rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

6
src/client/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

12
src/client/main.tsx Normal file
View File

@ -0,0 +1,12 @@
import "./index.css";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@ -0,0 +1,9 @@
export default function ErrorMessage(
{ children }: { children: React.ReactNode },
) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground text-2xl">{children}</p>
</div>
);
}

34
src/client/pages/Home.tsx Normal file
View File

@ -0,0 +1,34 @@
import OrderCard from "@/components/order-card";
import { useOrderAutoRefresh, useOrders } from "@/hooks/useOrders";
import { useQueryClient } from "@tanstack/react-query";
import ErrorMessage from "./ErrorPage";
import LoadingPage from "./Loading";
export default function Home() {
const { data: orders, status } = useOrders();
const queryClient = useQueryClient();
useOrderAutoRefresh(queryClient);
if (status === "pending") {
return <LoadingPage />;
}
if (!orders) {
return <ErrorMessage> .</ErrorMessage>;
}
const sortedOrders = orders.sort((a, b) => {
// id format: "{date}/{order_number}"
const [, anum] = a.id.split("/");
const [, bnum] = b.id.split("/");
return parseInt(bnum) - parseInt(anum);
});
return (
<>
{sortedOrders.length === 0 && <ErrorMessage> .</ErrorMessage>}
<section>
{sortedOrders.map((order) => <OrderCard key={order.id} order={order} />)}
</section>
</>
);
}

View File

@ -0,0 +1,10 @@
export default function LoadingPage() {
return (
<div className="flex items-center justify-center h-full">
<div>
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Loading...</h1>
<p className="text-xl text-muted-foreground">This page is loading...</p>
</div>
</div>
);
}

273
src/client/pages/Menu.tsx Normal file
View File

@ -0,0 +1,273 @@
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { useAddMenuItem, useDeleteMenuItem, useMenu, useUpdateMenuItem } from "@/hooks/useMenu";
import { cn } from "@/lib/utils";
import { TrashIcon } from "@radix-ui/react-icons";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { MenuItemColumns } from "src/db";
import { UpdateMenuItemType } from "src/schema";
import ErrorMessage from "./ErrorPage";
import LoadingPage from "./Loading";
function currencyFormat(price: number) {
return price.toLocaleString("ko-KR", {
style: "currency",
currency: "KRW",
});
}
function onlyNumber(str: string) {
return str.replace(/[^0-9]/g, "");
}
function MenuInput({
data,
onChange,
className = "",
}: {
data: UpdateMenuItemType;
onChange: (data: UpdateMenuItemType) => void;
className?: string;
}) {
return (
<div className={cn("grid gap-2", className)}>
<div>
<div className="text-muted-foreground"></div>
<Input
value={data.category}
onChange={(e) =>
onChange({
...data,
category: e.target.value,
})}
/>
</div>
<div>
<div className="text-muted-foreground">Hot </div>
<Input
value={data.HOT ? (data.HOT * 1000).toString() : ""}
onChange={(e) =>
onChange({
...data,
HOT: e.target.value ? parseInt(onlyNumber(e.target.value)) / 1000 : null,
})}
/>
</div>
<div>
<div className="text-muted-foreground">Ice </div>
<Input
value={data.COLD ? (data.COLD * 1000).toString() : ""}
onChange={(e) =>
onChange({
...data,
COLD: e.target.value ? parseInt(onlyNumber(e.target.value)) / 1000 : null,
})}
/>
</div>
<div>
<div className="text-muted-foreground"> </div>
<Input
value={data.price ? (data.price * 1000).toString() : ""}
onChange={(e) =>
onChange({
...data,
price: e.target.value ? parseInt(onlyNumber(e.target.value)) / 1000 : null,
})}
/>
</div>
</div>
);
}
function MenuItem({
item,
}: {
item: MenuItemColumns;
}) {
const [drawerOpen, setDrawerOpen] = useState(false);
const [deleteDrawerOpen, setDeleteDrawerOpen] = useState(false);
const queryClient = useQueryClient();
const { mutate: mutateUpdate, isPending: isUpdatePending } = useUpdateMenuItem(queryClient);
const { mutate: mutateDelete, isPending: isDeletePending } = useDeleteMenuItem(queryClient);
const [data, setData] = useState<UpdateMenuItemType>({
category: item.category,
HOT: item.HOT,
COLD: item.COLD,
price: item.price,
});
return (
<>
<Drawer open={deleteDrawerOpen} onOpenChange={setDeleteDrawerOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{item.name} </DrawerTitle>
<DrawerDescription> .</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Button
variant="destructive"
disabled={isDeletePending}
onClick={() => {
mutateDelete(item.name, {
onSuccess: () => {
setDeleteDrawerOpen(false);
},
});
}}
>
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerTrigger asChild>
<div className="border p-4 hover:bg-accent relative">
<Button
variant="ghost"
disabled={isDeletePending}
className="absolute top-4 right-2"
onClick={(e) => {
setDeleteDrawerOpen(true);
e.stopPropagation();
}}
>
<TrashIcon />
</Button>
<span className="text-muted-foreground font-light text-sm leading-3">{item.category}</span>
<h2 className="text-xl font-bold">{item.name}</h2>
{item.HOT && <p className="text-sm text-muted-foreground">HOT {currencyFormat(item.HOT * 1000)}</p>}
{item.COLD && <p className="text-sm text-muted-foreground">COLD {currencyFormat(item.COLD * 1000)}</p>}
{item.price && <p className="text-sm text-muted-foreground">{currencyFormat(item.price * 1000)}</p>}
</div>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{item.name} </DrawerTitle>
<DrawerDescription> .</DrawerDescription>
</DrawerHeader>
<MenuInput className="m-4" data={data} onChange={setData} />
<DrawerFooter>
<Button
variant="default"
disabled={isUpdatePending}
onClick={() => {
mutateUpdate({
...item,
...data,
}, {
onSuccess: () => {
setDrawerOpen(false);
},
});
}}
>
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
);
}
function AddMenuItem() {
const queryClient = useQueryClient();
const { mutate, isPending } = useAddMenuItem(queryClient);
const [drawerOpen, setDrawerOpen] = useState(false);
const [data, setData] = useState<MenuItemColumns>({
name: "",
category: "",
HOT: null,
COLD: null,
price: null,
});
return (
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerTrigger asChild>
<div className="border p-4 hover:bg-accent flex items-center justify-center">
<h2 className="text-xl font-bold"> </h2>
</div>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle> </DrawerTitle>
<DrawerDescription> .</DrawerDescription>
</DrawerHeader>
<div className="m-4">
<div className="mb-2">
<div className="text-muted-foreground"> </div>
<Input
value={data.name}
onChange={(e) =>
setData({
...data,
name: e.target.value,
})}
/>
</div>
<MenuInput
data={data}
onChange={(c) => {
setData({
...data,
...c,
});
}}
/>
</div>
<DrawerFooter>
<Button
variant="default"
disabled={isPending}
onClick={() => {
mutate(data, {
onSuccess: () => {
setData({
name: "",
category: "",
HOT: null,
COLD: null,
price: null,
});
setDrawerOpen(false);
},
});
}}
>
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
export default function MenuPage() {
const { data: menuItems, status } = useMenu();
if (status === "pending") {
return <LoadingPage />;
}
if (!menuItems) {
return <ErrorMessage> .</ErrorMessage>;
}
return (
<div className="grid sm:grid-cols-2 grid-cols-1 md:grid-cols-3 lg:grid-cols-4">
{menuItems.map((item) => <MenuItem key={item.name} item={item} />)}
<AddMenuItem />
</div>
);
}

View File

@ -0,0 +1,10 @@
export default function NotFound() {
return (
<div className="flex items-center justify-center h-full">
<div>
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">404 Not Found</h1>
<p className="text-xl text-muted-foreground"> .</p>
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
import OrderComponent from "@/components/order";
import { useMenu } from "@/hooks/useMenu";
import ErrorMessage from "./ErrorPage";
import LoadingPage from "./Loading";
export default function Order() {
const { data: menu, isLoading } = useMenu();
if (isLoading) {
return <LoadingPage />;
}
if (!menu) {
return <ErrorMessage> .</ErrorMessage>;
}
const categories = [...(new Set(menu.map(item => item.category)))];
return (
<div className="p-4">
<OrderComponent menus={menu} categories={categories} />
</div>
);
}

79
src/client/pages/Stat.tsx Normal file
View File

@ -0,0 +1,79 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useOrders } from "@/hooks/useOrders";
import { Order } from "src/db";
import ErrorMessage from "./ErrorPage";
import LoadingPage from "./Loading";
function StatItem({ title, value }: { title: string; value: string }) {
return (
<div className="flex flex-col">
<p className="text-muted-foreground text-sm">{title}</p>
<p className="text-lg">{value}</p>
</div>
);
}
function sum(...args: number[]) {
return args.reduce((acc, cur) => acc + cur, 0);
}
function ordersSum(orders: Order[]) {
return orders.reduce((acc, cur) => acc + cur.price * cur.quantity * 1000, 0);
}
export default function Stat() {
const { data: orders, status } = useOrders();
if (status === "pending") {
return <LoadingPage />;
}
if (!orders) {
return <ErrorMessage> .</ErrorMessage>;
}
const completed_order = orders.filter(order => order.completed);
const cash_order = completed_order.filter(order => order.payment === "cash");
const account_order = completed_order.filter(order => order.payment === "account");
return (
<Card className="w-[300px] m-auto">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2">
<StatItem title="총 주문 수" value={orders.length.toString()} />
<StatItem title="완료된 주문 수" value={completed_order.length.toString()} />
<StatItem title="현금 결제 주문 수" value={cash_order.length.toString()} />
<StatItem title="계좌 이체 주문 수" value={account_order.length.toString()} />
</div>
<hr className="my-2" />
<div className="grid grid-cols-2">
<StatItem
title="총 매출"
value={sum(...completed_order.map(order => ordersSum(order.orders)))
.toLocaleString("ko-KR", {
style: "currency",
currency: "KRW",
})}
/>
<StatItem
title="현금 매출"
value={sum(...cash_order.map(order => ordersSum(order.orders)))
.toLocaleString("ko-KR", {
style: "currency",
currency: "KRW",
})}
/>
<StatItem
title="계좌 매출"
value={sum(...account_order.map(order => ordersSum(order.orders)))
.toLocaleString("ko-KR", {
style: "currency",
currency: "KRW",
})}
/>
</div>
</CardContent>
</Card>
);
}

8
src/client/tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx"
}
}

1
src/client/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

49
src/db.ts Normal file
View File

@ -0,0 +1,49 @@
import { Insertable, JSONColumnType, Selectable, Updateable } from "kysely";
export type MenuItem = {
name: string;
HOT: number | null;
COLD: number | null;
price: number | null;
category: string;
};
export type NewMenuItem = Insertable<MenuItem>;
export type MenuItemColumns = Selectable<MenuItem>;
export type MenuItemUpdate = Updateable<MenuItem>;
export interface Order {
name: string;
quantity: number;
price: number;
// SQLite has no Boolean datatype. Use cast(1|0|SqliteBoolean).
HOT: 0 | 1;
}
export type PaymentMethod = "account" | "cash";
export interface OrderState {
id: string;
orders: JSONColumnType<Order[]>;
completed: 0 | 1;
payment: PaymentMethod;
}
export type NewOrderState = Insertable<OrderState>;
export type OrderStateColumns = Selectable<OrderState>;
export type OrderStateUpdate = Updateable<OrderState>;
export interface OrderNumber {
id: string;
number: number;
}
export type NewOrderNumber = Insertable<OrderNumber>;
export type OrderNumberColumns = Selectable<OrderNumber>;
export type OrderNumberUpdate = Updateable<OrderNumber>;
export interface Database {
Order_State: OrderState;
Order_Number: OrderNumber;
Menu: MenuItem;
}

47
src/schema.ts Normal file
View File

@ -0,0 +1,47 @@
import { z } from "zod";
export const addNewMenuItemSchema = z.object({
HOT: z.number().nullable(),
COLD: z.number().nullable(),
price: z.number().nullable(),
category: z.string(),
});
export type AddNewMenuItemType = z.infer<typeof addNewMenuItemSchema>;
export const updateMenuItemSchema = z.object({
HOT: z.number().nullable().optional(),
COLD: z.number().nullable().optional(),
price: z.number().nullable().optional(),
category: z.string().optional(),
});
export type UpdateMenuItemType = z.infer<typeof updateMenuItemSchema>;
const paymentMethodSchema = z.enum(["account", "cash"]);
export type PaymentMethod = z.infer<typeof paymentMethodSchema>;
/**
* SQLite has no Boolean datatype. Use cast(true|false|SqliteBoolean).
* https://www.sqlite.org/quirks.html#no_separate_boolean_datatype
*/
export const SqliteBooleanSchema = z
.union([z.literal(0), z.literal(1)]);
export type SqliteBoolean = z.infer<typeof SqliteBooleanSchema>;
const orderSchema = z.object({
name: z.string(),
quantity: z.number(),
price: z.number(),
HOT: SqliteBooleanSchema,
});
export type OrderType = z.infer<typeof orderSchema>;
export const addNewOrderSchema = z.object({
orders: z.array(orderSchema),
payment: paymentMethodSchema,
});
export type AddNewOrderType = z.infer<typeof addNewOrderSchema>;

182
src/server/db.ts Normal file
View File

@ -0,0 +1,182 @@
import "dotenv/config";
import SQLite from "better-sqlite3";
import { existsSync } from "fs";
import { Kysely, SqliteDialect } from "kysely";
import { Database, MenuItemUpdate, NewMenuItem, NewOrderState, Order, PaymentMethod } from "../db.ts"; // this is the Database interface we defined earlier
let migration = 0;
const dbName = process.env.DB_PATH || "order.sqlite3";
if (existsSync(dbName)) {
console.log("Database exists");
} else {
migration = 1;
}
const sqlite3DB = new SQLite(dbName);
if (migration > 0) {
sqlite3DB.exec(`
CREATE TABLE Order_State (
id TEXT PRIMARY KEY,
orders TEXT NOT NULL,
completed INTEGER NOT NULL,
payment TEXT NOT NULL
);
CREATE TABLE Order_Number (
id TEXT PRIMARY KEY,
number INTEGER NOT NULL
);
CREATE TABLE Menu (
name TEXT PRIMARY KEY,
HOT INTEGER,
COLD INTEGER,
price INTEGER,
category TEXT NOT NULL
);
INSERT INTO Menu (name, HOT, COLD, category, price) VALUES
('아메리카노', 1.5, 2, '커피',NULL),
('헤이즐넛 아메리카노', 1.5, 2, '커피',NULL),
('바닐라 아메리카노', 1.5, 2, '커피',NULL),
('꿀단지 커피', 2.5, 3, '커피',NULL),
('곰다방커피', NULL, 2, '커피',NULL),
('콜드브루', NULL, 2, '커피',NULL),
('카페모카', 2, 2.5, '커피',NULL),
('카페라떼', 3, 3.5, '카페라떼',NULL),
('헤이즐넛 카페라떼', 3, 3.5, '카페라떼',NULL),
('바닐라 카페라떼', 3, 3.5, '카페라떼',NULL),
('초코라떼', 3, 3.5, '카페라떼',NULL),
('딸기라떼', 3, 3.5, '카페라떼',NULL),
('사과유자차', 3, 3.5, '차',NULL),
('꿀유자차', 2, 2.5, '차',NULL),
('폴라복숭아', NULL, 2.5, '차',NULL),
('허니자몽 블랙티', 2.5, 3, '차',NULL),
('오미자 차', 2, 2.5, '차',NULL),
('달곰상곰 딸기레몬', 3.5, 3.5, '탄산',NULL),
('청포도 에이드', 3, 3, '탄산',NULL),
('자몽에이드', 3, 3, '탄산',NULL),
('레몬에이드', 3, 3, '탄산',NULL),
('오미자 에이드', 3, 3, '탄산',NULL),
('허니브레드', NULL, NULL, '빵', 3),
('소금빵', NULL, NULL, '빵', 3),
('크로크무슈', NULL, NULL, '빵', 3.5);
`);
}
const dialect = new SqliteDialect({
database: sqlite3DB,
});
const db = new Kysely<Database>({
dialect,
});
export async function requestOrderNumber(): Promise<number> {
const date = new Date().toISOString().split("T")[0];
return await db.transaction().execute(async trx => {
const order_number = await trx.selectFrom("Order_Number")
.where("id", "==", date)
.selectAll()
.executeTakeFirst();
if (!order_number) {
await trx.insertInto("Order_Number")
.values({
id: date,
number: 1,
})
.execute();
return 1;
}
const num = order_number.number + 1;
await trx.updateTable("Order_Number")
.set("number", num)
.where("id", "==", date)
.execute();
return num;
});
}
export async function saveOrder(order: Order[], payment: PaymentMethod) {
const date = new Date().toISOString().split("T")[0];
const num = await requestOrderNumber();
const id = `${date}/${num}`;
await db.insertInto("Order_State")
.values({
id,
orders: JSON.stringify(order),
completed: 0,
payment,
} as NewOrderState)
.execute();
return id;
}
export async function loadOrder() {
const orders = (await db.selectFrom("Order_State")
.selectAll()
.orderBy(["completed", "id desc"])
.execute())
.map(order => ({
...order,
orders: JSON.parse(order.orders as unknown as string) as Order[],
}));
return orders;
}
export async function clearOrder() {
await db.deleteFrom("Order_State")
.execute();
}
export async function cancelOrder(order_id: string) {
await db.deleteFrom("Order_State")
.where("id", "==", order_id)
.execute();
}
export async function completeOrder(uid: string, completed: 0 | 1 = 1) {
await db.updateTable("Order_State")
.set("completed", completed)
.where("id", "==", uid)
.execute();
}
export async function loadOrderById(uid: string) {
const order = await db.selectFrom("Order_State")
.where("id", "==", uid)
.selectAll()
.executeTakeFirst();
return order
? {
...order,
orders: JSON.parse(order.orders as unknown as string) as Order[],
}
: null;
}
export async function addNewMenuItem(item: NewMenuItem) {
await db.insertInto("Menu")
.values(item)
.execute();
}
export async function listMenuItems() {
return await db.selectFrom("Menu")
.selectAll()
.orderBy(["category", "name"])
.execute();
}
export async function deleteMenuItem(name: string) {
await db.deleteFrom("Menu")
.where("name", "==", name)
.execute();
}
export async function updateMenuItem(name: string, item: MenuItemUpdate) {
await db.updateTable("Menu")
.set(item)
.where("name", "==", name)
.execute();
}

View File

@ -0,0 +1,32 @@
import { NextFunction, Request, Response } from "express";
import { ZodError } from "zod";
export function errorWrapper(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await fn(req, res, next);
} catch (e) {
errorHandler(e, req, res, next);
}
};
}
export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) {
if (err instanceof ZodError) {
res.status(400).json({
error: err.errors,
errorType: "ZodError",
message: `Invalid request body: ${err.message}`,
});
return;
}
if (err instanceof Error) {
console.error(err);
res.status(500).json({ error: err.message });
} else {
// This should never happen. If it does, it's a bug in the server code.
// error must be an instance of Error.
console.error(err);
res.status(500).json({ error: "An unknown error occurred" });
}
}

27
src/server/main.ts Normal file
View File

@ -0,0 +1,27 @@
import express from "express";
import ViteExpress from "vite-express";
import MenuRouter from "./menu.ts";
import OrderRouter from "./order.ts";
import "dotenv/config";
import { errorHandler } from "./error_handler.ts";
const app = express();
app.set("etag", false);
app.use("/api", express.urlencoded({ extended: true }));
app.use("/api", express.json());
app.use("/api", MenuRouter);
app.use("/api", OrderRouter);
app.get("/api/alive", (_, res) => {
res.send("Alive!");
});
app.use(errorHandler);
ViteExpress.config({
ignorePaths: /\/api/,
});
const portNum = parseInt(process.env.SERVER_PORT ?? "NaN");
const port = isNaN(portNum) ? 3000 : portNum;
ViteExpress.listen(app, port, () => console.log(`Server is listening on port ${port}...`));

44
src/server/menu.ts Normal file
View File

@ -0,0 +1,44 @@
import { Router } from "express";
import { updateMenuItemSchema } from "src/schema.ts";
import { addNewMenuItemSchema } from "../schema.ts";
import { addNewMenuItem, deleteMenuItem, listMenuItems, updateMenuItem } from "./db.ts";
import { errorWrapper } from "./error_handler.ts";
const router = Router();
router.get(
"/menu",
errorWrapper(async (req, res) => {
res.json(await listMenuItems());
}),
);
router.post(
"/menu/:name",
errorWrapper(async (req, res, _) => {
const name = req.params.name;
const item = addNewMenuItemSchema.parse(req.body);
await addNewMenuItem({ ...item, name });
res.json({ success: true });
}),
);
router.patch(
"/menu/:name",
errorWrapper(async (req, res) => {
const name = req.params.name;
const item = updateMenuItemSchema.parse(req.body);
await updateMenuItem(name, item);
res.json({ success: true });
}),
);
router.delete(
"/menu/:name",
errorWrapper(async (req, res) => {
await deleteMenuItem(req.params.name);
res.json({ success: true });
}),
);
export default router;

125
src/server/order.ts Normal file
View File

@ -0,0 +1,125 @@
import { Router } from "express";
import { addNewOrderSchema } from "src/schema.ts";
import * as api from "./db.ts";
import { errorWrapper } from "./error_handler.ts";
const router = Router();
const listeners = new Set<() => void>();
function addOrderListener(listener: () => void) {
listeners.add(listener);
}
function removeOrderListener(listener: () => void) {
listeners.delete(listener);
}
function emitOrder() {
for (const listener of listeners) {
listener();
}
}
router.get(
"/order",
errorWrapper(async (req, res) => {
res.json(await api.loadOrder());
}),
);
router.get(
"/order/notify",
errorWrapper(async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
const listener = () => {
res.write(
`data: ${
JSON.stringify({
change: "order",
})
}\n\n`,
(err) => {
if (err) {
// we could not send the event to the client anymore
// so we could not call next()
// only log the error
console.error(err);
}
},
);
};
addOrderListener(listener);
req.on("close", () => {
removeOrderListener(listener);
});
}),
);
router.get(
"/order/:id",
errorWrapper(async (req, res) => {
const order = await api.loadOrderById(req.params.id);
if (order) {
res.json(order);
} else {
res.status(404).json({ error: "Order not found" });
}
}),
);
router.post(
"/order",
errorWrapper(async (req, res) => {
const orderReq = addNewOrderSchema.parse(req.body);
const orders = orderReq.orders;
const payment = orderReq.payment;
const id = await api.saveOrder(orders, payment);
emitOrder();
res.json({ id });
}),
);
router.delete(
"/order/:id",
errorWrapper(async (req, res) => {
await api.cancelOrder(req.params.id);
emitOrder();
res.json({ success: true });
}),
);
router.post(
"/order/:id/complete",
errorWrapper(async (req, res) => {
await api.completeOrder(req.params.id, 1);
emitOrder();
res.json({ success: true });
}),
);
router.post(
"/order/:id/cancel-complete",
errorWrapper(async (req, res) => {
await api.completeOrder(req.params.id, 0);
emitOrder();
res.json({ success: true });
}),
);
router.delete(
"/order",
errorWrapper(async (req, res) => {
await api.clearOrder();
emitOrder();
res.json({ success: true });
}),
);
export default router;

77
tailwind.config.js Normal file
View File

@ -0,0 +1,77 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
};

24
tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/client/*"]
}
},
"include": ["src"]
}

13
vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src/client"),
},
},
});