diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96fab4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +node_modules +.pnp +.pnp.js + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage + +# Turbo +.turbo + +# Vercel +.vercel + +# Build Outputs +.next/ +out/ +build +dist + + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..e69de29 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..44a73ec --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "eslint.workingDirectories": [ + { + "mode": "auto" + } + ] +} diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 0000000..506e4c3 --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,2 @@ +# deps +node_modules/ diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..2f3a446 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,20 @@ +{ + "name": "api", + "scripts": { + "dev": "bun run --watch src/index.ts" + }, + "dependencies": { + "@bull-board/api": "^5.21.4", + "@bull-board/hono": "^5.21.4", + "@hono/zod-openapi": "^0.16.0", + "@scalar/hono-api-reference": "^0.5.144", + "bullmq": "^5.12.13", + "hono": "^4.5.11", + "ky": "^1.7.2", + "ts-khqr": "^2.1.3", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..0024dae --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,40 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { apiReference } from "@scalar/hono-api-reference"; +import { registerTasker } from "./lib/tasker"; +import { Route } from "./route"; + +import { cors } from "hono/cors"; +// Tasker +import { TransactionTasker } from "./route/transaction/tasker"; + +const app = new OpenAPIHono(); + +// The OpenAPI documentation will be available at /doc +app.doc31("/openapi", { + openapi: "3.1.0", + info: { + version: "1.0.0", + title: "My API", + }, +}); + +app.get( + "/docs", + apiReference({ + theme: "purple", + spec: { + url: "/openapi", + }, + }), +); + +registerTasker(app, [new TransactionTasker()]); + +app.use( + "*", + cors({ origin: "*", allowHeaders: ["Content-Type"], allowMethods: ["GET", "POST"] }), +); + +app.route("/", Route); + +export default app; diff --git a/apps/api/src/lib/route.ts b/apps/api/src/lib/route.ts new file mode 100644 index 0000000..4d87813 --- /dev/null +++ b/apps/api/src/lib/route.ts @@ -0,0 +1,21 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import type { ZodSchema } from "zod"; + +export const createRoute: OpenAPIHono["openapi"] = (routeConfig, handler) => { + return new OpenAPIHono().openapi(routeConfig, handler); +}; + +export interface ResponseConfig { + schema: ZodSchema; + description: string; + example?: any; +} + +export function response(config: ResponseConfig) { + return { + content: { + "application/json": { schema: config.schema, example: config.example }, + }, + description: config.description, + }; +} diff --git a/apps/api/src/lib/tasker.ts b/apps/api/src/lib/tasker.ts new file mode 100644 index 0000000..ca25f49 --- /dev/null +++ b/apps/api/src/lib/tasker.ts @@ -0,0 +1,34 @@ +import { createBullBoard } from "@bull-board/api"; +import { BullMQAdapter } from "@bull-board/api/bullMQAdapter"; +import { HonoAdapter } from "@bull-board/hono"; +import type { OpenAPIHono } from "@hono/zod-openapi"; +import { Queue, type Worker } from "bullmq"; +import { serveStatic } from "hono/bun"; + +export function registerTasker(app: OpenAPIHono, taskers: Tasker[]) { + for (const tasker of taskers) { + tasker.start(); + } + + const serverAdapter = new HonoAdapter(serveStatic); + + createBullBoard({ + queues: taskers.map((tasker) => new BullMQAdapter(new Queue(tasker.worker.name))), + serverAdapter, + }); + + serverAdapter.setBasePath("/queue"); + + app.route("/queue", serverAdapter.registerPlugin()); +} + +export const DEFAULT_CONNECTION = { + host: "127.0.0.1", + port: 6379, +}; + +export interface Tasker { + worker: Worker; + start(): Promise; + shutdown(): Promise; +} diff --git a/apps/api/src/route/index.ts b/apps/api/src/route/index.ts new file mode 100644 index 0000000..7196ff1 --- /dev/null +++ b/apps/api/src/route/index.ts @@ -0,0 +1,9 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { logger } from "hono/logger"; +import { TransactionRoute } from "./transaction"; +import { UserRoute } from "./user"; + +export const Route = new OpenAPIHono() + .use(logger()) + .route("/", UserRoute) + .route("/", TransactionRoute); diff --git a/apps/api/src/route/transaction/config.ts b/apps/api/src/route/transaction/config.ts new file mode 100644 index 0000000..0d32233 --- /dev/null +++ b/apps/api/src/route/transaction/config.ts @@ -0,0 +1 @@ +export const BAKONG_API_URL = "https://api-bakong.nbc.gov.kh"; diff --git a/apps/api/src/route/transaction/index.ts b/apps/api/src/route/transaction/index.ts new file mode 100644 index 0000000..1f29b77 --- /dev/null +++ b/apps/api/src/route/transaction/index.ts @@ -0,0 +1,9 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { createTransaction } from "./route/transaction.create"; +import { getTransactionByMd5 } from "./route/transaction.get-by-md5"; +import { trackTransaction } from "./route/transaction.track"; + +export const TransactionRoute = new OpenAPIHono() + .route("/", createTransaction) + .route("/", trackTransaction) + .route("/", getTransactionByMd5); diff --git a/apps/api/src/route/transaction/queue.ts b/apps/api/src/route/transaction/queue.ts new file mode 100644 index 0000000..3c497c7 --- /dev/null +++ b/apps/api/src/route/transaction/queue.ts @@ -0,0 +1,29 @@ +import { DEFAULT_CONNECTION } from "@/lib/tasker"; +import { Queue } from "bullmq"; + +export const TRANSACTION_QUEUE_NAME = "{transaction}"; + +export class TransactionQueue { + queue; + + constructor() { + this.queue = new Queue(TRANSACTION_QUEUE_NAME, { + connection: DEFAULT_CONNECTION, + }); + } + + async add(md5: string) { + await this.queue.add( + md5, + { md5 }, + { + jobId: md5, + attempts: 60, + delay: 5000, + backoff: { type: "fixed", delay: 3000 }, + }, + ); + } +} + +export const transactionQueue = new TransactionQueue(); diff --git a/apps/api/src/route/transaction/route/transaction.create.ts b/apps/api/src/route/transaction/route/transaction.create.ts new file mode 100644 index 0000000..004e284 --- /dev/null +++ b/apps/api/src/route/transaction/route/transaction.create.ts @@ -0,0 +1,37 @@ +import { createRoute, response } from "@/lib/route"; +import { transactionServcie } from "@/service/transaction.service"; +import { z } from "@hono/zod-openapi"; +import { transactionQueue } from "../queue"; + +export const createTransaction = createRoute( + { + method: "post", + path: "/transaction/create", + request: { + body: { content: { "application/json": { schema: z.object({ amount: z.number() }) } } }, + }, + responses: { + 200: response({ + description: "Transaction created", + schema: z.object({ data: z.any() }), + }), + 400: response({ + description: "Error", + schema: z.object({ error: z.string() }), + }), + }, + }, + async (c) => { + const { amount } = await c.req.json(); + + const transaction = transactionServcie.createTransaction(amount); + + if (!transaction.data) { + return c.json({ error: "sth went wrong" }, 400); + } + + // await transactionQueue.add(transaction.data.md5); + + return c.json({ data: transaction.data }); + }, +); diff --git a/apps/api/src/route/transaction/route/transaction.get-by-md5.ts b/apps/api/src/route/transaction/route/transaction.get-by-md5.ts new file mode 100644 index 0000000..dd160fd --- /dev/null +++ b/apps/api/src/route/transaction/route/transaction.get-by-md5.ts @@ -0,0 +1,22 @@ +import { response } from "@/lib/route"; +import { transactionServcie } from "@/service/transaction.service"; +import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; + +export const getTransactionByMd5 = new OpenAPIHono().openapi( + createRoute({ + method: "get", + path: "/transaction/get-by-md5/{md5}", + request: { params: z.object({ md5: z.string() }) }, + responses: { + 200: response({ + schema: z.object({ data: z.any() }), + description: "Transaction details", + }), + }, + }), + async (c) => { + const md5 = c.req.param("md5"); + const transaction = await transactionServcie.getTransactionByMd5(md5); + return c.json({ data: transaction }); + }, +); diff --git a/apps/api/src/route/transaction/route/transaction.track.ts b/apps/api/src/route/transaction/route/transaction.track.ts new file mode 100644 index 0000000..25d5357 --- /dev/null +++ b/apps/api/src/route/transaction/route/transaction.track.ts @@ -0,0 +1,52 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { streamSSE } from "hono/streaming"; +import { z } from "zod"; +import { transactionQueue } from "../queue"; + +export const trackTransaction = new OpenAPIHono().openapi( + createRoute({ + path: "/transaction/track/{md5}", + method: "get", + request: { params: z.object({ md5: z.string() }) }, + responses: { + 200: { description: "Track a transaction" }, + }, + }), + async (c) => { + const md5 = c.req.param("md5"); + + if (md5 === undefined) { + return c.json({ error: "MD5 is required" }, 400); + } + + return streamSSE( + c, + async (stream) => { + stream.onAbort(async () => { + stream.abort(); + await stream.close(); + }); + + while (true) { + const job = await transactionQueue.queue.getJob(md5); + const isCompleted = (await job?.isCompleted()) ?? false; + const isFailed = (await job?.isFailed()) ?? false; + + if (isCompleted) { + await stream.writeSSE({ data: "COMPLETED" }); + await stream.close(); + } else if (isFailed) { + await stream.writeSSE({ data: "FAILED" }); + await stream.close(); + } + + await stream.writeSSE({ data: "PENDING" }); + await stream.sleep(3000); + } + }, + async (error, stream) => { + await stream.writeln(`ERROR: ${error.message}`); + }, + ); + }, +); diff --git a/apps/api/src/route/transaction/tasker.ts b/apps/api/src/route/transaction/tasker.ts new file mode 100644 index 0000000..bc95e80 --- /dev/null +++ b/apps/api/src/route/transaction/tasker.ts @@ -0,0 +1,57 @@ +import { transactionServcie } from "@/service/transaction.service"; +import { type Job, UnrecoverableError, Worker } from "bullmq"; +import ky from "ky"; +import { DEFAULT_CONNECTION, type Tasker } from "../../lib/tasker"; +import { BAKONG_API_URL } from "./config"; +import { TRANSACTION_QUEUE_NAME } from "./queue"; +import type { TransactionSuccess } from "./type"; + +export class TransactionTasker implements Tasker { + worker; + + constructor() { + this.worker = new Worker(TRANSACTION_QUEUE_NAME, this.process, { + autorun: false, + connection: DEFAULT_CONNECTION, + concurrency: 20, + }); + + this.worker.on("ready", () => { + console.log("Transaction worker is ready"); + }); + + this.worker.on("closing", () => { + console.log("Transaction worker is closing"); + }); + } + + async start() { + await this.worker.run(); + } + + async shutdown() { + await this.worker.close(); + } + + async process(job: Job<{ md5: string }>) { + // if the job age exceeds 5mn, it will be removed + if (job.timestamp + 300000 < Date.now()) { + throw new UnrecoverableError("Job is too old"); + } + + const transactionStatus = await transactionServcie.getTransactionByMd5(job.data.md5); + + if (transactionStatus.responseCode === 1) { + if (transactionStatus.errorCode === 3) { + // transaction failed + throw new UnrecoverableError(transactionStatus.responseMessage); + } + + // not found error + throw new Error(transactionStatus.responseMessage); + } + + await job.updateProgress(100); + job.log("Transaction is successful"); + } +} diff --git a/apps/api/src/route/transaction/type.ts b/apps/api/src/route/transaction/type.ts new file mode 100644 index 0000000..0cc8719 --- /dev/null +++ b/apps/api/src/route/transaction/type.ts @@ -0,0 +1,31 @@ +export interface TransactionSuccess { + responseCode: 0; + responseMessage: "Getting transaction successfully."; + errorCode: null; + data: { + hash: string; + fromAccountId: string; + toAccountId: string; + currency: string; + amount: number; + description: string; + createdDateMs: number; + acknowledgedDateMs: number; + }; +} + +export interface TransactionFailed { + responseCode: 1; + responseMessage: "Transaction failed."; + errorCode: 3; + data: null; +} + +export interface TransactionNotFound { + responseCode: 1; + responseMessage: "Transaction could not be found. Please check and try again."; + errorCode: 1; + data: null; +} + +export type TransactionResponse = TransactionSuccess | TransactionFailed | TransactionNotFound; diff --git a/apps/api/src/route/user/index.ts b/apps/api/src/route/user/index.ts new file mode 100644 index 0000000..a7fa343 --- /dev/null +++ b/apps/api/src/route/user/index.ts @@ -0,0 +1,20 @@ +import { OpenAPIHono, z } from "@hono/zod-openapi"; +import { createRoute, response } from "../../lib/route"; + +const GetUsers = createRoute( + { + method: "get", + path: "/users", + responses: { + 200: response({ + description: "List of users", + schema: z.object({ users: z.array(z.string()) }), + }), + }, + }, + async (c) => { + return c.json({ users: ["henlo"] }); + }, +); + +export const UserRoute = new OpenAPIHono().route("/", GetUsers); diff --git a/apps/api/src/service/transaction.service.ts b/apps/api/src/service/transaction.service.ts new file mode 100644 index 0000000..200444d --- /dev/null +++ b/apps/api/src/service/transaction.service.ts @@ -0,0 +1,51 @@ +import { BAKONG_API_URL } from "@/route/transaction/config"; +import type { TransactionResponse } from "@/route/transaction/type"; +import ky from "ky"; +import { CURRENCY, KHQR, TAG } from "ts-khqr"; + +const ACCOUNT_ID = "vichiny_vouch@aclb"; + +class TransactionService { + private token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoiNjJjMWIzMWQ4NWFlNDdkIn0sImlhdCI6MTcyNTQ3Mzc4NSwiZXhwIjoxNzMzMjQ5Nzg1fQ.Tedo-oYI7h9L3KjSIfDN3ovO_5EIRbMpPekvDg-oxt4"; + private api = ky.extend({ + prefixUrl: BAKONG_API_URL, + }); + + createTransaction(amount: number) { + const khqr = KHQR.generate({ + tag: TAG.INDIVIDUAL, + merchantName: "Vichiny Vouch", + accountID: ACCOUNT_ID, + currency: CURRENCY.KHR, + amount, + }); + + return khqr; + } + + async getTransactionByMd5(md5: string) { + const transaction = await this.api + .post("v1/check_transaction_by_md5", { + json: { md5 }, + headers: { Authorization: `Bearer ${this.token}` }, + }) + .json(); + + return transaction; + } + + async getTransactions() { + // Get all transactions + } + + async updateTransaction() { + // Update a transaction + } + + async deleteTransaction() { + // Delete a transaction + } +} + +export const transactionServcie = new TransactionService(); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..f834552 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "src/*" + ] + } + } +} diff --git a/apps/web/app.config.ts b/apps/web/app.config.ts new file mode 100644 index 0000000..bf54917 --- /dev/null +++ b/apps/web/app.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "@tanstack/start/config"; + +export default defineConfig({ + tsr: { + appDirectory: "./app", + routesDirectory: "./app/route", + generatedRouteTree: "./app/route.gen.ts", + }, +}); diff --git a/apps/web/app/client.tsx b/apps/web/app/client.tsx new file mode 100644 index 0000000..acb66f8 --- /dev/null +++ b/apps/web/app/client.tsx @@ -0,0 +1,7 @@ +import { StartClient } from "@tanstack/start"; +import { hydrateRoot } from "react-dom/client"; +import { createRouter } from "./router"; + +const router = createRouter(); + +hydrateRoot(document.getElementById("root")!, ); diff --git a/apps/web/app/component/confirmation.tsx b/apps/web/app/component/confirmation.tsx new file mode 100644 index 0000000..ec6cd66 --- /dev/null +++ b/apps/web/app/component/confirmation.tsx @@ -0,0 +1,79 @@ +import { $confirmationAtom } from "@/lib/confirmation"; +import { useStore } from "@nanostores/react"; +import { Button, Dialog, Flex, Spinner } from "@radix-ui/themes"; +import { useState } from "react"; +import { Fragment } from "react/jsx-runtime"; + +export function Confirmation() { + const confirmation = useStore($confirmationAtom); + const [isPending, setIsPending] = useState(false); + + const handleOpenChange = (open: boolean) => { + if (!confirmation) return; + $confirmationAtom.set({ ...confirmation, open }); + }; + + const handleCancel = () => { + if (!confirmation) return; + confirmation.onCancel?.(); + $confirmationAtom.set({ ...confirmation, open: false }); + }; + + const handleConfirm = async () => { + if (!confirmation) return; + + // check if the confirmation function returns a promise + const result = confirmation.onConfirm?.(); + + // if it's a promise, set the pending state + // and wait for the promise to resolve + if (result instanceof Promise) { + setIsPending(true); + return await result.catch(console.error).finally(() => setIsPending(false)); + } + + // if it's not a promise, just close the dialog + $confirmationAtom.set({ ...confirmation, open: false }); + }; + + const color = confirmation?.type === "destructive" ? "red" : undefined; + + return ( + + { + if (confirmation?.important) { + event.preventDefault(); + } + }} + > + {confirmation?.customContent && } + + {confirmation && !confirmation.customContent && ( + + {confirmation.title} + + {confirmation.description} + + + + + + + + )} + + + ); +} diff --git a/apps/web/app/component/icon/car.tsx b/apps/web/app/component/icon/car.tsx new file mode 100644 index 0000000..37813b0 --- /dev/null +++ b/apps/web/app/component/icon/car.tsx @@ -0,0 +1,12 @@ +export const Car = (props: React.SVGProps) => ( + + car + + +); diff --git a/apps/web/app/component/icon/github.tsx b/apps/web/app/component/icon/github.tsx new file mode 100644 index 0000000..6f3d1fd --- /dev/null +++ b/apps/web/app/component/icon/github.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from "react"; + +export const BrandGithub = (props: SVGProps) => ( + + Github Logo + + +); diff --git a/apps/web/app/component/icon/globe.tsx b/apps/web/app/component/icon/globe.tsx new file mode 100644 index 0000000..8c26964 --- /dev/null +++ b/apps/web/app/component/icon/globe.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from "react"; + +export function IconGlobe(props: SVGProps) { + return ( + + Globe Icon + + + ); +} diff --git a/apps/web/app/component/icon/google.tsx b/apps/web/app/component/icon/google.tsx new file mode 100644 index 0000000..2a347ba --- /dev/null +++ b/apps/web/app/component/icon/google.tsx @@ -0,0 +1,30 @@ +import type { SVGProps } from "react"; + +export const BrandGoogle = (props: SVGProps) => ( + + Google Logo + + + + + +); diff --git a/apps/web/app/component/icon/shirt-folded.tsx b/apps/web/app/component/icon/shirt-folded.tsx new file mode 100644 index 0000000..3f77aa5 --- /dev/null +++ b/apps/web/app/component/icon/shirt-folded.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from "react"; + +export const IconShirtFolded = (props: SVGProps) => ( + + shirt folded + + +); diff --git a/apps/web/app/component/ui/form.tsx b/apps/web/app/component/ui/form.tsx new file mode 100644 index 0000000..48d7776 --- /dev/null +++ b/apps/web/app/component/ui/form.tsx @@ -0,0 +1,175 @@ +import type * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import * as React from "react"; +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { Label } from "@/component/ui/label"; +import { cn } from "@/lib/cn"; +import { Flex, type FlexProps, Text, type TextProps } from "@radix-ui/themes"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext({} as FormItemContextValue); + +const FormItem = React.forwardRef( + ({ className, direction = "column", gap = "2", ...props }, ref) => { + const id = React.useId(); + + return ( + + + + ); + }, +); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { required?: boolean } +>(({ className, required, children, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + const requiredAsterisk = required && ( + + * + + ); + + return ( +