Compare commits
14 commits
master
...
realtime-u
Author | SHA1 | Date | |
---|---|---|---|
|
eedf038963 | ||
fa704809ec | |||
7e35b48a03 | |||
655085cb6d | |||
b14a49803e | |||
0de2f649e8 | |||
|
9c3c6c6363 | ||
|
c34e8e91b5 | ||
|
4a957bce10 | ||
|
210f84a0b3 | ||
|
85091594ce | ||
|
9b55048720 | ||
2d5c711979 | |||
6c4130aaf3 |
17 changed files with 3009 additions and 134 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -18,6 +18,7 @@
|
|||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.vscode
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
@ -26,7 +27,9 @@ yarn-error.log*
|
|||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env*
|
||||
.local
|
||||
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
|
8
.prettierrc.json5
Normal file
8
.prettierrc.json5
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
tabWidth: 2,
|
||||
semi: true,
|
||||
useTabs: false,
|
||||
singleQuote: false,
|
||||
trailingComma: "es5",
|
||||
printWidth: 80,
|
||||
}
|
|
@ -35,7 +35,6 @@ export default function Layout({ children }: Props) {
|
|||
[license]
|
||||
</a>
|
||||
·
|
||||
|
||||
{/* eslint-disable @next/next/no-img-element */}
|
||||
<a href="https://github.com/iptq/wisesplit/stargazers">
|
||||
<img
|
||||
|
|
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
|
@ -0,0 +1,9 @@
|
|||
version: "3"
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
|
||||
db:
|
||||
image: mongo
|
||||
ports: [27017:27017]
|
24
lib/getMongoDBClient.ts
Normal file
24
lib/getMongoDBClient.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { Db, MongoClient } from "mongodb";
|
||||
|
||||
const USERNAME = process.env.MONGO_USERNAME;
|
||||
const PASSWORD = process.env.MONGO_PASSWORD;
|
||||
const HOSTNAME = process.env.MONGO_HOSTNAME;
|
||||
const DATABASE_NAME = process.env.MONGO_DATABASE_NAME;
|
||||
const DATABASE_PORT = process.env.MONGO_DATABASE_PORT;
|
||||
|
||||
const userInfo = USERNAME && PASSWORD ? `${USERNAME}:${PASSWORD}@` : "";
|
||||
const URI = `mongodb://${userInfo}${HOSTNAME ?? "localhost"}:${DATABASE_PORT}`;
|
||||
|
||||
let db: Db | null = null;
|
||||
|
||||
export const getMongoDBClient = async () => {
|
||||
if (db) {
|
||||
return db;
|
||||
}
|
||||
const client = new MongoClient(URI);
|
||||
|
||||
await client.connect();
|
||||
db = client.db(DATABASE_NAME);
|
||||
|
||||
return db;
|
||||
};
|
47
lib/jotaiUtil.ts
Normal file
47
lib/jotaiUtil.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { atom, Getter, PrimitiveAtom } from "jotai";
|
||||
|
||||
export function storedAtom<T>(initial: T) {
|
||||
return atom(initial);
|
||||
}
|
||||
|
||||
export function unwrapAtom<T>(get: Getter, obj: T, depth: number = 0): unknown {
|
||||
const log = (...s: any) => {
|
||||
// console.log(" ".repeat(depth), ...s);
|
||||
};
|
||||
log("Unwrapping", obj);
|
||||
let atom, result;
|
||||
|
||||
// Recursively try to unwrap atoms
|
||||
if ((atom = isAtom(obj))) {
|
||||
let innerObj = get(atom);
|
||||
log("Got atom with obj", innerObj);
|
||||
result = unwrapAtom(get, innerObj, depth + 1);
|
||||
} else if (Array.isArray(obj)) {
|
||||
log("Got array");
|
||||
result = obj.map((item) => unwrapAtom(get, item, depth + 1));
|
||||
} else if (typeof obj == "object" && obj !== null) {
|
||||
log("Got object");
|
||||
result = Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => [
|
||||
key,
|
||||
unwrapAtom(get, value, depth + 1),
|
||||
])
|
||||
);
|
||||
} else {
|
||||
log("Got else", typeof obj);
|
||||
result = obj;
|
||||
}
|
||||
|
||||
log("Result", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function isAtom<T>(obj: T): PrimitiveAtom<unknown> | null {
|
||||
if (typeof obj != "object") return null;
|
||||
if (obj == null) return null;
|
||||
if (obj.constructor != Object) return null;
|
||||
|
||||
// Heuristically check the fields
|
||||
if (!("init" in obj && "write" in obj && "read" in obj)) return null;
|
||||
return obj;
|
||||
}
|
2
lib/server/realtime.ts
Normal file
2
lib/server/realtime.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
// TODO: Move this to some redis-like persistence layer
|
||||
// Or figure out how to do sharding based on room ID
|
|
@ -2,11 +2,14 @@ import { atom, PrimitiveAtom } from "jotai";
|
|||
import { SetAtom } from "jotai/core/atom";
|
||||
import { IPerson } from "../components/Person";
|
||||
import { IReceiptItem, Receipt } from "../components/ReceiptItem";
|
||||
import { unwrapAtom } from "./jotaiUtil";
|
||||
import parseInput from "./parseInput";
|
||||
|
||||
export const totalAtom = atom(0);
|
||||
export const receiptAtom = atom<PrimitiveAtom<IReceiptItem>[]>([]);
|
||||
|
||||
export const unwrappedReceiptAtom = atom((get) => unwrapAtom(get, receiptAtom));
|
||||
|
||||
export const receiptTotalAtom = atom((get) => {
|
||||
const totalValue = get(totalAtom);
|
||||
const receipt = get(receiptAtom);
|
||||
|
|
2730
package-lock.json
generated
2730
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -9,14 +9,17 @@
|
|||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.8.6",
|
||||
"@reduxjs/toolkit": "^1.9.0",
|
||||
"bootstrap": "^5.2.2",
|
||||
"jotai": "^1.8.6",
|
||||
"mongodb": "^4.11.0",
|
||||
"next": "12.3.1",
|
||||
"react": "18.2.0",
|
||||
"react-bootstrap": "^2.5.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-redux": "^8.0.4",
|
||||
"react-redux": "^8.0.5",
|
||||
"socket.io": "^4.5.3",
|
||||
"socket.io-client": "^4.5.3",
|
||||
"styled-components": "^5.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -26,6 +29,7 @@
|
|||
"@types/styled-components": "^5.1.26",
|
||||
"eslint": "8.26.0",
|
||||
"eslint-config-next": "12.3.1",
|
||||
"prettier": "^2.7.1",
|
||||
"typescript": "4.8.4"
|
||||
}
|
||||
}
|
||||
|
|
20
pages/api/createReceipt.ts
Normal file
20
pages/api/createReceipt.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getMongoDBClient } from "../../lib/getMongoDBClient";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Record<string, string>[] | Record<string, string>>
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Only POST method allowed" });
|
||||
}
|
||||
|
||||
const receipt = req.body;
|
||||
const client = await getMongoDBClient();
|
||||
|
||||
const receipts = client.collection("receipts");
|
||||
const newReceipt = await receipts.insertOne({});
|
||||
const id = newReceipt.insertedId.toString();
|
||||
|
||||
res.json({ id });
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
type Data = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
) {
|
||||
res.status(200).json({ name: 'John Doe' })
|
||||
}
|
35
pages/api/socket.ts
Normal file
35
pages/api/socket.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Server } from "socket.io";
|
||||
|
||||
const io = new Server({});
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
|
||||
const receiptId = req.body;
|
||||
|
||||
console.log(receiptId);
|
||||
|
||||
// if (res.socket?.server.io) {
|
||||
// console.log("Socket is already running");
|
||||
// res.end();
|
||||
// return;
|
||||
// }
|
||||
|
||||
// console.log("Socket is initializing");
|
||||
// const io = new Server(res.socket.server);
|
||||
// res.socket.server.io = io;
|
||||
|
||||
// io.on("connection", (socket) => {
|
||||
// console.log("Received new connection");
|
||||
|
||||
// socket.on("input-change", (msg) => {
|
||||
// socket.broadcast.emit("update-input", msg);
|
||||
// });
|
||||
// });
|
||||
|
||||
res.status(200);
|
||||
res.end();
|
||||
}
|
20
pages/api/updateReceipt.ts
Normal file
20
pages/api/updateReceipt.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getMongoDBClient } from "../../lib/getMongoDBClient";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Record<string, string>[] | Record<string, string>>
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Only POST method allowed" });
|
||||
}
|
||||
const receipt = req.body;
|
||||
const client = await getMongoDBClient();
|
||||
|
||||
try {
|
||||
// TODO implement this
|
||||
res.status(200).json(receipt);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: (e as Error).message });
|
||||
}
|
||||
}
|
|
@ -1,84 +1,22 @@
|
|||
import { useAtom } from "jotai";
|
||||
import type { NextPage } from "next";
|
||||
import { SyntheticEvent, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import NumberEditBox from "../components/NumberEditBox";
|
||||
import ReceiptItem from "../components/ReceiptItem";
|
||||
import { moneyFormatter } from "../lib/formatter";
|
||||
import { ParsedInputDisplay } from "../lib/parseInput";
|
||||
import {
|
||||
addLine,
|
||||
receiptAtom,
|
||||
receiptTotalAtom,
|
||||
totalAtom,
|
||||
} from "../lib/state";
|
||||
import { NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const Home: NextPage = () => {
|
||||
const [receipt, setReceipt] = useAtom(receiptAtom);
|
||||
const [input, setInput] = useState("");
|
||||
const [total] = useAtom(totalAtom);
|
||||
const [calculated] = useAtom(receiptTotalAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const formatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useEffect(() => {
|
||||
const newPage = async () => {
|
||||
let res = await fetch("/api/createReceipt", { method: "POST" });
|
||||
let result = await res.json();
|
||||
let id = result.id;
|
||||
if (typeof id != "string") return;
|
||||
router.push(`/receipt/${id}`);
|
||||
};
|
||||
newPage();
|
||||
});
|
||||
|
||||
const add = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
addLine(input, receipt, setReceipt);
|
||||
setInput("");
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Items</h1>
|
||||
|
||||
<Form onSubmit={add}>
|
||||
<ParsedInputDisplay input={input} />
|
||||
|
||||
<Form.Control
|
||||
autoFocus={true}
|
||||
type="text"
|
||||
placeholder="Add item..."
|
||||
onInput={(e) => setInput(e.currentTarget.value)}
|
||||
value={input}
|
||||
style={{ padding: "8px 16px", fontSize: "1.5em" }}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<div>
|
||||
Receipt Total:
|
||||
<span style={total < calculated.subtotal ? { color: "red" } : {}}>
|
||||
<NumberEditBox
|
||||
valueAtom={totalAtom}
|
||||
formatter={moneyFormatter.format}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{receipt.map((itemAtom, i) => {
|
||||
return <ReceiptItem itemAtom={itemAtom} key={`receiptItem-${i}`} />;
|
||||
})}
|
||||
|
||||
{calculated.totalMap.size > 0 && (
|
||||
<>
|
||||
<h3>Weighted Breakdown</h3>
|
||||
|
||||
<div>
|
||||
<ul>
|
||||
{[...calculated.totalMap.entries()].map(([person, value], i) => (
|
||||
<li key={`breakdown-${i}`}>
|
||||
<b>{person}</b>: {formatter.format(value)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
|
127
pages/receipt/[id].tsx
Normal file
127
pages/receipt/[id].tsx
Normal file
|
@ -0,0 +1,127 @@
|
|||
import { useAtom, atom } from "jotai";
|
||||
import type { NextPage } from "next";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { SyntheticEvent, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import NumberEditBox from "components/NumberEditBox";
|
||||
import ReceiptItem from "components/ReceiptItem";
|
||||
import { moneyFormatter } from "lib/formatter";
|
||||
import { ParsedInputDisplay } from "lib/parseInput";
|
||||
import {
|
||||
addLine,
|
||||
receiptAtom,
|
||||
receiptTotalAtom,
|
||||
totalAtom,
|
||||
unwrappedReceiptAtom,
|
||||
} from "lib/state";
|
||||
import { io } from "socket.io-client";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
let socket;
|
||||
|
||||
const ReceiptPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const [receipt, setReceipt] = useAtom(receiptAtom);
|
||||
const [input, setInput] = useState("");
|
||||
const [total] = useAtom(totalAtom);
|
||||
const [calculated] = useAtom(receiptTotalAtom);
|
||||
const [unwrappedReceipt] = useAtom(unwrappedReceiptAtom);
|
||||
|
||||
const { pathname } = router;
|
||||
|
||||
// Connect to the socket server
|
||||
useEffect(() => {
|
||||
|
||||
console.log(pathname);
|
||||
const socketInitializer = async () => {
|
||||
await fetch("/api/socket", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ receiptId: id }),
|
||||
});
|
||||
// socket = io();
|
||||
|
||||
// socket.on("connect", () => {
|
||||
// console.log("connected");
|
||||
// });
|
||||
};
|
||||
|
||||
socketInitializer();
|
||||
}, []);
|
||||
|
||||
const formatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
});
|
||||
|
||||
const add = async (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
addLine(input, receipt, setReceipt);
|
||||
|
||||
const payload = unwrappedReceipt;
|
||||
console.log("Payload", payload);
|
||||
fetch("/api/createReceipt", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
setInput("");
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Items</h1>
|
||||
|
||||
<Form onSubmit={add}>
|
||||
<ParsedInputDisplay input={input} />
|
||||
|
||||
<Form.Control
|
||||
autoFocus={true}
|
||||
type="text"
|
||||
placeholder="Add item..."
|
||||
onInput={(e) => setInput(e.currentTarget.value)}
|
||||
value={input}
|
||||
style={{ padding: "8px 16px", fontSize: "1.5em" }}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
{receipt.map((itemAtom, i) => {
|
||||
return <ReceiptItem itemAtom={itemAtom} key={`receiptItem-${i}`} />;
|
||||
})}
|
||||
|
||||
<div>
|
||||
Receipt Total:
|
||||
<span style={total < calculated.subtotal ? { color: "red" } : {}}>
|
||||
<NumberEditBox
|
||||
valueAtom={totalAtom}
|
||||
formatter={moneyFormatter.format}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{calculated.totalMap.size > 0 && (
|
||||
<>
|
||||
<h3>Weighted Breakdown</h3>
|
||||
|
||||
<div>
|
||||
<ul>
|
||||
{[...calculated.totalMap.entries()].map(([person, value], i) => (
|
||||
<li key={`breakdown-${i}`}>
|
||||
<b>{person}</b>: {formatter.format(value)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReceiptPage;
|
|
@ -14,6 +14,7 @@
|
|||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
|
|
Loading…
Reference in a new issue