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
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
.vscode
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
@ -26,7 +27,9 @@ yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*
|
||||||
|
.local
|
||||||
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.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]
|
[license]
|
||||||
</a>
|
</a>
|
||||||
·
|
·
|
||||||
|
|
||||||
{/* eslint-disable @next/next/no-img-element */}
|
{/* eslint-disable @next/next/no-img-element */}
|
||||||
<a href="https://github.com/iptq/wisesplit/stargazers">
|
<a href="https://github.com/iptq/wisesplit/stargazers">
|
||||||
<img
|
<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 { SetAtom } from "jotai/core/atom";
|
||||||
import { IPerson } from "../components/Person";
|
import { IPerson } from "../components/Person";
|
||||||
import { IReceiptItem, Receipt } from "../components/ReceiptItem";
|
import { IReceiptItem, Receipt } from "../components/ReceiptItem";
|
||||||
|
import { unwrapAtom } from "./jotaiUtil";
|
||||||
import parseInput from "./parseInput";
|
import parseInput from "./parseInput";
|
||||||
|
|
||||||
export const totalAtom = atom(0);
|
export const totalAtom = atom(0);
|
||||||
export const receiptAtom = atom<PrimitiveAtom<IReceiptItem>[]>([]);
|
export const receiptAtom = atom<PrimitiveAtom<IReceiptItem>[]>([]);
|
||||||
|
|
||||||
|
export const unwrappedReceiptAtom = atom((get) => unwrapAtom(get, receiptAtom));
|
||||||
|
|
||||||
export const receiptTotalAtom = atom((get) => {
|
export const receiptTotalAtom = atom((get) => {
|
||||||
const totalValue = get(totalAtom);
|
const totalValue = get(totalAtom);
|
||||||
const receipt = get(receiptAtom);
|
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"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "^1.8.6",
|
"@reduxjs/toolkit": "^1.9.0",
|
||||||
"bootstrap": "^5.2.2",
|
"bootstrap": "^5.2.2",
|
||||||
"jotai": "^1.8.6",
|
"jotai": "^1.8.6",
|
||||||
|
"mongodb": "^4.11.0",
|
||||||
"next": "12.3.1",
|
"next": "12.3.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-bootstrap": "^2.5.0",
|
"react-bootstrap": "^2.5.0",
|
||||||
"react-dom": "18.2.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"
|
"styled-components": "^5.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -26,6 +29,7 @@
|
||||||
"@types/styled-components": "^5.1.26",
|
"@types/styled-components": "^5.1.26",
|
||||||
"eslint": "8.26.0",
|
"eslint": "8.26.0",
|
||||||
"eslint-config-next": "12.3.1",
|
"eslint-config-next": "12.3.1",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
"typescript": "4.8.4"
|
"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 { NextPage } from "next";
|
||||||
import type { NextPage } from "next";
|
import { useRouter } from "next/router";
|
||||||
import { SyntheticEvent, useState } from "react";
|
import { useEffect } 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";
|
|
||||||
|
|
||||||
const Home: NextPage = () => {
|
const Home: NextPage = () => {
|
||||||
const [receipt, setReceipt] = useAtom(receiptAtom);
|
const router = useRouter();
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [total] = useAtom(totalAtom);
|
|
||||||
const [calculated] = useAtom(receiptTotalAtom);
|
|
||||||
|
|
||||||
const formatter = new Intl.NumberFormat("en-US", {
|
useEffect(() => {
|
||||||
style: "currency",
|
const newPage = async () => {
|
||||||
currency: "USD",
|
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) => {
|
return <></>;
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Home;
|
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,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
|
"baseUrl": ".",
|
||||||
"incremental": true
|
"incremental": true
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
|
Loading…
Reference in a new issue