diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml index dee42a57..09c1fd70 100644 --- a/.github/workflows/cd-staging.yml +++ b/.github/workflows/cd-staging.yml @@ -55,12 +55,39 @@ jobs: ghcr.io/${{ github.repository_owner }}/website/merch:${{ github.sha }} ghcr.io/${{ github.repository_owner }}/website/merch:latest + + build-web: + name: Build Web next.js app + runs-on: ubuntu-22.04 + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Setup Node.js environment + uses: actions/setup-node@v3 + with: + node-version: 16 + cache: "yarn" + + - name: Install dependencies + run: yarn install --prefer-offline --frozen-lockfile + + - name: Build web + run: yarn turbo run build --filter=web + + - name: Cache CD Staging workflow for dependent jobs + uses: actions/cache/save@v3 + with: + path: . + key: CD-Staging-${{ runner.os }}-${{ github.ref }}-${{ github.run_id }} + deploy-to-staging: name: Deploy To Staging runs-on: ubuntu-22.04 needs: - build-cms - build-merch + - build-web steps: - name: Checkout repo uses: actions/checkout@v3 @@ -81,3 +108,33 @@ jobs: docker compose pull && docker compose up -d ' + + deploy-to-vercel: + name: Deploy To Staging + runs-on: ubuntu-22.04 + needs: + - build-cms + - build-merch + - build-web + steps: + - name: Get build cache + uses: actions/cache/restore@v3 + with: + path: . + key: CD-Staging-${{ runner.os }}-${{ github.ref }}-${{ github.run_id }} + fail-on-cache-miss: true + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + - name: Pull Vercel Environment Information + run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} + + - name: Copy Build Artifacts to .vercel dir + run: vercel build --token=${{ secrets.VERCEL_TOKEN }} + + - name: Deploy Project Artifacts to Vercel + id: deploy-to-vercel + run: | + DEPLOYMENT_URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}) + vercel alias --token=${{ secrets.VERCEL_TOKEN }} set "$DEPLOYMENT_URL" dev.ntuscse.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12e79126..8cabbc71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,17 +6,26 @@ on: pull_request: types: [opened, synchronize] +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} # remote caching + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} # remote caching + jobs: build: name: Lint, Build and Test timeout-minutes: 15 runs-on: ubuntu-latest - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} # remote caching - TURBO_TEAM: ${{ secrets.TURBO_TEAM }} # remote caching - WORDPRESS_API_URL: ${{ secrets.WORDPRESS_API_URL }} # for web steps: + - name: Grab cache + uses: actions/cache/restore@v3 + with: + path: . + key: CI-${{ runner.os }}-${{ github.ref }}-${{ github.run_id }} + restore-keys: | + CI-${{ runner.os }}-${{ github.ref }}- + CI-${{ runner.os }}-refs/head/main- + - name: Check out code uses: actions/checkout@v3 with: @@ -47,7 +56,121 @@ jobs: echo "${{steps.prettier-run.outputs.prettier_output}}" echo "⭐️ You should run `yarn format` in the root of the project directory to fix these files" + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_CI }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_CI }} + aws-region: ap-southeast-1 + - name: Lint + Build + Unit Test - run: yarn turbo run lint build test cypress:start-headless --color + run: yarn turbo run lint build:ci --color env: + #merch + AWS_REGION: 'ap-southeast-1' + PRODUCT_TABLE_NAME: 'be-dev-products' + ORDER_TABLE_NAME: 'be-dev-orders' + ORDER_HOLD_TABLE_NAME: 'be-dev-orders-hold' + # web WORDPRESS_API_URL: 'https://clubs.ntu.edu.sg/csec/graphql' + NEXT_PUBLIC_MERCH_API_ORIGIN: 'http://127.0.0.1:3002' + + - name: Cache CI workflow for dependent jobs + uses: actions/cache/save@v3 + with: + path: . + key: CI-${{ runner.os }}-${{ github.ref }}-${{ github.run_id }} + + preview: + name: Vercel Preview - web + timeout-minutes: 15 + runs-on: ubuntu-latest + needs: build + permissions: + deployments: write + pull-requests: write + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID_WEB }} + + steps: + - name: Get build cache + uses: actions/cache/restore@v3 + with: + path: . + key: CI-${{ runner.os }}-${{ github.ref }}-${{ github.run_id }} + fail-on-cache-miss: true + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + - name: Pull Vercel Environment Information + run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} + + - name: Copy Build Artifacts to .vercel dir + run: vercel build --token=${{ secrets.VERCEL_TOKEN }} + + - name: Deploy Project Artifacts to Vercel + id: deploy-to-vercel + run: | + DEPLOYMENT_URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}) + echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT" + + - name: Print Deployment URL + env: + DEPLOYMENT_URL: ${{ steps.deploy-to-vercel.outputs.deployment-url }} + run: echo $DEPLOYMENT_URL + + - uses: chrnorm/deployment-action@v2 + name: Create GitHub deployment + id: deployment + with: + token: '${{ github.token }}' + environment-url: '${{ steps.deploy-to-vercel.outputs.deployment-url }}' + environment: Preview - website + + - name: Update deployment status (success) + if: success() + uses: chrnorm/deployment-status@v2 + with: + token: '${{ github.token }}' + environment-url: '${{ steps.deploy-to-vercel.outputs.deployment-url }}' + state: 'success' + deployment-id: ${{ steps.deployment.outputs.deployment_id }} + + - name: Update deployment status (failure) + if: failure() + uses: chrnorm/deployment-status@v2 + with: + token: '${{ github.token }}' + state: 'failure' + deployment-id: ${{ steps.deployment.outputs.deployment_id }} + + - name: Comment PR with preview link if success + if: success() + uses: thollander/actions-comment-pull-request@v2 + env: + DEPLOYMENT_URL: ${{ steps.deploy-to-vercel.outputs.deployment-url }} + with: + message: | + Deployment preview link: ${{ env.DEPLOYMENT_URL }} + comment_tag: execution + mode: recreate + + + - name: Comment PR with preview link if fail + if: failure() + uses: thollander/actions-comment-pull-request@v2 + env: + DEPLOYMENT_URL: ${{ steps.deploy-to-vercel.outputs.deployment-url }} + with: + message: | + Deployment preview link: ❌ + comment_tag: execution + mode: recreate + + + + + diff --git a/.gitignore b/.gitignore index abbfb81b..6e61c6c5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,15 +13,11 @@ node_modules coverage # build +**/.next **/build **/out **/dist -# next.js -.next/ -out/ -build - # misc .DS_Store *.pem @@ -44,6 +40,9 @@ yarn-error.log* # turbo .turbo +# vercel +.vercel + # jetbrains .idea diff --git a/CODEOWNERS b/CODEOWNERS index 5ac1819f..987b6be1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -8,6 +8,8 @@ /apps/cms/** @jamiegoh /apps/merch/** @chanbakjsd /apps/web/** @xJQx +/apps/web/features/merch/** @chanbakjsd +/apps/web/pages/merch/** @chanbakjsd /packages/eslint-custom-config/** @realdyllon /packages/nodelogger/** @realdyllon @@ -18,4 +20,5 @@ /packages/types/lib/cms.ts @jamiegoh /packages/types/lib/merch.ts @chanbakjsd /packages/ui/** @xJQx +/packages/ui/merch/** @chanbakjsd diff --git a/apps/cms/.env.example b/apps/cms/.env.example index 8ba55f46..53574c44 100644 --- a/apps/cms/.env.example +++ b/apps/cms/.env.example @@ -1,7 +1,7 @@ MONGODB_URI=mongodb://localhost/cms PAYLOAD_SECRET= -PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000 -PAYLOAD_PUBLIC_SERVER_PORT=3000 +PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3003 +PAYLOAD_PUBLIC_SERVER_PORT=3003 FRONTEND_STAGING_DOMAIN=https://dev.ntuscse.com S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= diff --git a/apps/cms/package.json b/apps/cms/package.json index 6bfcf754..1561cc7a 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -9,6 +9,8 @@ "build:payload": "yarn generate:types && PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build", "build:server": "tsc", "build": "yarn copyfiles && yarn build:payload && yarn build:server", + "build:ci": "yarn run build", + "clean": "rm -rf dist && rm -rf build", "serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js BABEL_DISABLE_CACHE=1 NODE_ENV=production node dist/server.js", "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/", "generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types", diff --git a/apps/merch/.env.example b/apps/merch/.env.example new file mode 100644 index 00000000..b90efd39 --- /dev/null +++ b/apps/merch/.env.example @@ -0,0 +1,6 @@ +AWS_REGION= +PRODUCT_TABLE_NAME= +ORDER_TABLE_NAME= +ORDER_HOLD_TABLE_NAME= +CORS_ORIGIN=http://localhost:3001 +STRIPE_SECRET_KEY= diff --git a/apps/merch/package.json b/apps/merch/package.json index afe34cf1..79df3555 100644 --- a/apps/merch/package.json +++ b/apps/merch/package.json @@ -6,21 +6,35 @@ "license": "Apache-2.0", "scripts": { "build": "tsup src/index.ts --format cjs", + "build:ci": "yarn run build", "start": "node dist/index.js", + "start:ci": "nohup yarn run start > /dev/null 2>&1 &", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist", "dev": "tsup src/index.ts --format cjs --watch --onSuccess \"node dist/index.js\"", "lint": "TIMING=1 eslint \"**/*.ts*\"", "lint:fix": "TIMING=1 eslint --fix \"**/*.ts*\"" }, "dependencies": { - "express": "^4.17.1", - "nodelogger": "*" + "@aws-sdk/client-dynamodb": "^3.289.0", + "@aws-sdk/util-dynamodb": "^3.289.0", + "@trpc/server": "^10.31.0", + "add": "^2.0.6", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "nodelogger": "*", + "stripe": "^12.5.0", + "trpc-panel": "^1.3.4", + "uuid": "^9.0.0", + "yarn": "^1.22.19", + "zod": "^3.21.4" }, "devDependencies": { "@swc/core": "^1.3.64", "@types/cookie-parser": "^1.4.3", - "@types/express": "^4.17.9", - "@types/morgan": "^1.9.4", + "@types/cors": "^2.8.13", + "@types/express": "^4.17.17", + "@types/uuid": "^9.0.1", "cookie-parser": "^1.4.6", "morgan": "^1.10.0", "nodemon": "^2.0.6", @@ -28,7 +42,6 @@ "tsconfig": "*", "tsup": "^6.7.0", "types": "*", - "typescript": "^4.8.4", - "winston": "^3.8.2" + "typescript": "^4.8.4" } } diff --git a/apps/merch/src/db/dynamodb.ts b/apps/merch/src/db/dynamodb.ts new file mode 100644 index 00000000..c64ba5eb --- /dev/null +++ b/apps/merch/src/db/dynamodb.ts @@ -0,0 +1,111 @@ +import { + ConditionalCheckFailedException, + DynamoDB, + GetItemCommand, + PutItemCommand, + ScanCommand, + UpdateItemCommand, +} from "@aws-sdk/client-dynamodb"; +import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; +import { Logger } from "nodelogger"; +import { Order } from "types"; + +const ORDER_TABLE_NAME = process.env.ORDER_TABLE_NAME ?? ""; + +// Due to Babel transpiling, extending Error will cause instanceof to not work +// properly. +export class NotFoundError { + message: string; + + constructor(item = "") { + this.message = "Item " + item + " not found."; + } +} + +export const getOrders = () => readTable(ORDER_TABLE_NAME); +export const getOrder = (id: string) => + readItem(ORDER_TABLE_NAME, id, "orderID"); + +const client = new DynamoDB({ region: process.env.AWS_REGION }); + +// readTable scans the table for all entries, refusing to read more when the +// queried data exceeds 1MB. +export const readTable = async (tableName: string): Promise => { + const command = new ScanCommand({ TableName: tableName }); + const response = await client.send(command); + if (response.LastEvaluatedKey) { + Logger.warn( + `LastEvaluatedKey was not undefined when querying ${tableName}, dropping additional items` + ); + } + if (!response.Items) { + return []; + } + return response.Items.map((item) => unmarshall(item)) as T[]; +}; + +// readItem retrieves the specified item from the table. +export const readItem = async ( + tableName: string, + key: string, + keyID = "id" +): Promise => { + const command = new GetItemCommand({ + TableName: tableName, + Key: marshall({ [keyID]: key }), + }); + const response = await client.send(command); + if (!response.Item) { + throw new NotFoundError(key); + } + return unmarshall(response.Item) as T; +}; + +// writeItem adds the given item to the specified DynamoDB table +export const writeItem = async ( + tableName: string, + item: T +): Promise => { + const command = new PutItemCommand({ + TableName: tableName, + Item: marshall(item), + }); + try { + await client.send(command); + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + Logger.warn(`Item already exists in table ${tableName}`); + return; + } + throw error; + } +}; + +// updateItem updates the specified item in the table. +export const updateItem = async ( + tableName: string, + key: string, + updateExpression?: string, + conditionExpression?: string, + expressionAttributeValues?: Record, + expressionAttributeNames?: Record, + keyID = "id" +): Promise => { + const command = new UpdateItemCommand({ + TableName: tableName, + Key: marshall({ [keyID]: key }), + ConditionExpression: conditionExpression, + UpdateExpression: updateExpression, + ExpressionAttributeValues: marshall(expressionAttributeValues), + ExpressionAttributeNames: expressionAttributeNames, + }); + try { + await client.send(command); + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + Logger.warn(`Item does not exist in table ${tableName}`); + return; + } + throw error; + } +}; diff --git a/apps/merch/src/db/index.ts b/apps/merch/src/db/index.ts new file mode 100644 index 00000000..fad2ac14 --- /dev/null +++ b/apps/merch/src/db/index.ts @@ -0,0 +1,3 @@ +export { NotFoundError } from "./dynamodb"; +export * from "./orders"; +export * from "./products"; diff --git a/apps/merch/src/db/orders.ts b/apps/merch/src/db/orders.ts new file mode 100644 index 00000000..edff273b --- /dev/null +++ b/apps/merch/src/db/orders.ts @@ -0,0 +1,119 @@ +import { readItem, writeItem } from "./dynamodb"; +import { v4 as uuidv4 } from "uuid"; +import { Order, OrderItem, OrderStatus, OrderHoldEntry } from "types"; + +const ORDER_TABLE_NAME = process.env.ORDER_TABLE_NAME; +const ORDER_HOLD_TABLE_NAME = process.env.ORDER_HOLD_TABLE_NAME; + +if (!ORDER_TABLE_NAME) { + throw new Error("ORDER_TABLE_NAME is not defined"); +} +if (!ORDER_HOLD_TABLE_NAME) { + throw new Error("ORDER_HOLD_TABLE_NAME is not defined"); +} + +interface DynamoOrderItem { + id: string; + image: string; + quantity: number; + size: string; + price: number; + name: string; + colorway: string; + // product_category: string; +} + +interface DynamoOrder { + orderID: string; + paymentGateway: string; + orderItems: DynamoOrderItem[]; + status: OrderStatus; + customerEmail: string; + transactionID: string; + orderDateTime: string; +} + +interface DynamoOrderHoldEntry { + // todo +} + +export const getOrder = async (id: string) => { + const dynamoOrder = await readItem( + ORDER_TABLE_NAME ?? "", + id, + "orderID" + ); + return decodeOrder(dynamoOrder); +}; + +const decodeOrder = (order: DynamoOrder): Order => { + let date: string | null; + try { + date = new Date(order.orderDateTime).toISOString(); + } catch (e) { + date = null; + } + return { + id: order.orderID || "", + payment_method: order.paymentGateway || "", + items: order.orderItems.map((item) => ({ + id: item.id || "", + name: item.name || "", + // category: item.product_category || "", + image: item.image || undefined, + color: item.colorway || "", + size: item.size || "", + price: item.price || 0, + quantity: item.quantity || 1, + })), + status: order.status || OrderStatus.PENDING_PAYMENT, + customer_email: order.customerEmail || "", + transaction_id: order.transactionID || "", + transaction_time: date || null, + }; +}; + +const encodeOrderItem = (item: OrderItem): DynamoOrderItem => ({ + id: item.id, + image: item.image ? item.image : "", + quantity: item.quantity, + size: item.size, + price: item.price, + name: item.name, + colorway: item.color, + // product_category: item.category, +}); + +const encodeOrder = (order: Order): DynamoOrder => ({ + transactionID: order.transaction_id || "", + orderID: order.id, + paymentGateway: order.payment_method || "", + orderItems: order.items.map(encodeOrderItem), + status: order.status || OrderStatus.PENDING_PAYMENT, + customerEmail: order.customer_email || "", + orderDateTime: order.transaction_time + ? new Date(order.transaction_time).toISOString() + : new Date().toISOString(), +}); + +export const createOrder = async (order: Order): Promise => { + const dynamoOrder = encodeOrder(order); + dynamoOrder.orderID = uuidv4(); + await writeItem(ORDER_TABLE_NAME, dynamoOrder); + return decodeOrder(dynamoOrder); +}; + +const encodeOrderHoldEntry = ( + _orderHoldEntry: OrderHoldEntry +): DynamoOrderHoldEntry => { + return { + // todo + }; +}; + +export const createOrderHoldEntry = async ( + orderHoldEntry: OrderHoldEntry +): Promise => { + const dynamoOrderHoldEntry = encodeOrderHoldEntry(orderHoldEntry); + await writeItem(ORDER_HOLD_TABLE_NAME, dynamoOrderHoldEntry); +}; diff --git a/apps/merch/src/db/products.ts b/apps/merch/src/db/products.ts new file mode 100644 index 00000000..bb9c669d --- /dev/null +++ b/apps/merch/src/db/products.ts @@ -0,0 +1,77 @@ +import { readItem, readTable, updateItem } from "./dynamodb"; +import { Product } from "types"; + +const PRODUCT_TABLE_NAME = process.env.PRODUCT_TABLE_NAME; + +if (!PRODUCT_TABLE_NAME) { + throw new Error("PRODUCT_TABLE_NAME not defined"); +} + +export const getProducts = async () => { + const dynamoProducts = await readTable(PRODUCT_TABLE_NAME); + return dynamoProducts.map(decodeProduct); +}; +export const getProduct = async (id: string) => { + const dynamoProduct = await readItem(PRODUCT_TABLE_NAME, id); + return decodeProduct(dynamoProduct); +}; + +interface DynamoProduct { + id: string; + name: string; + price: number; + product_category: string; + size_chart?: string; + images: string[]; + colorways: string[]; + is_available: boolean; + sizes: string[]; + stock: { + [color: string]: { + [size: string]: number; + }; + }; +} + +const decodeProduct = (product: DynamoProduct): Product => { + return { + id: product.id || "", + name: product.name || "", + price: product.price || 0, + category: product.product_category || "", + size_chart: product.size_chart || undefined, + images: product.images || [], + colors: product.colorways || {}, + is_available: product.is_available || false, + sizes: product.sizes || [], + stock: product.stock || {}, + }; +}; + +export const incrementStockCount = async ( + item_id: string, + increment_value: number, + size: string, + color: string +): Promise => { + const update_expression = `ADD stock.#color.#size :incrementValue`; + const condition_expression = + "is_available = :isAvailable AND stock.#color.#size >= :incrementValue"; + const expression_attribute_values = { + ":incrementValue": { N: String(increment_value) }, + ":isAvailable": { BOOL: true }, + }; + const expression_attribute_names = { + "#color": color, + "#size": size, + }; + await updateItem( + PRODUCT_TABLE_NAME, + item_id, + update_expression, + condition_expression, + expression_attribute_values, + expression_attribute_names, + "id" + ); +}; diff --git a/apps/merch/src/index.ts b/apps/merch/src/index.ts index 4d237145..f8707836 100644 --- a/apps/merch/src/index.ts +++ b/apps/merch/src/index.ts @@ -1,24 +1,54 @@ -import express from "express" -import path from 'path' -import cookieParser from 'cookie-parser' -import { nodeloggerMiddleware, Logger } from "nodelogger"; - -// import routers -import indexRouter from './routes/index' -// import usersRouter from './routes/users' +import cookieParser from "cookie-parser"; +import cors from "cors"; +import express from "express"; +import { Logger, nodeloggerMiddleware } from "nodelogger"; +import path from "path"; +import { renderTrpcPanel } from "trpc-panel"; +import "dotenv/config"; +import { checkout } from "./routes/checkout"; +import { index, notFound } from "./routes/index"; +import { orderGet } from "./routes/orders"; +import { productGet, productsAll } from "./routes/products"; +import { appRouter, trpcMiddleware } from "./trpc/router"; const app = express(); +const CORS_ORIGIN = process.env.CORS_ORIGIN; +let corsMiddleware = cors(); +if (CORS_ORIGIN) { + corsMiddleware = cors({ origin: CORS_ORIGIN }); +} else { + Logger.warn("========================================"); + Logger.warn(" CORS_ORIGIN was not set for merch app. "); + Logger.warn("Defaulting to allowing all CORS request!"); + Logger.warn("========================================"); +} // middleware app.use(nodeloggerMiddleware); +app.use(corsMiddleware); app.use(express.json()); -app.use(express.urlencoded({ extended: false })); +app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); -app.use(express.static(path.join(__dirname, 'public'))); +app.use(express.static(path.join(__dirname, "public"))); + +// express routes +app.get("/", index); +app.get("/orders/:id", orderGet); +app.get("/products", productsAll); +app.get("/products/:id", productGet); +app.post("/checkout", checkout); + +// trpc +app.use("/trpc", trpcMiddleware); +app.use("/trpc-panel", (_, res) => { + return res.send( + renderTrpcPanel(appRouter, { url: "http://localhost:3002/trpc" }) + ); +}); -app.use('/', indexRouter); -// app.use('/users', usersRouter); +app.use(notFound); -app.listen("3000", ()=> Logger.info("server started on port 3000")) +const port = 3002; +app.listen(port, () => Logger.info(`server started on port ${port}`)); -export default app +export default app; diff --git a/apps/merch/src/lib/types.ts b/apps/merch/src/lib/types.ts new file mode 100644 index 00000000..dac9df75 --- /dev/null +++ b/apps/merch/src/lib/types.ts @@ -0,0 +1,11 @@ +import { APIError } from "types"; + +export interface Request { + body: unknown, + params: Record, +} + +export interface Response { + status: (code: number) => Response, + json: (response: T|APIError) => void, +} diff --git a/apps/merch/src/routes/checkout.ts b/apps/merch/src/routes/checkout.ts new file mode 100644 index 00000000..f6d7b901 --- /dev/null +++ b/apps/merch/src/routes/checkout.ts @@ -0,0 +1,153 @@ +import { calculatePricing, describeCart, PricingError } from "merch-helpers"; +import { Stripe } from "stripe"; +import { + CheckoutRequest, + CheckoutResponse, + Order, + // OrderHold, + OrderItem, + OrderStatus, + PricedCart, + Product, + // ReservedProduct, +} from "types"; +import { v4 as uuidv4 } from "uuid"; +import { + createOrder, + // createOrderHoldEntry, + getProducts, + // incrementStockCount, +} from "../db"; +import { Request, Response } from "../lib/types"; + +const ORDER_EXPIRY_TIME = parseInt(process.env.ORDER_EXPIRY_TIME ?? "24"); + +const STRIPE_KEY = process.env.STRIPE_SECRET_KEY || ""; +const stripe = new Stripe(STRIPE_KEY, { + apiVersion: "2022-11-15", +}); + +export const checkout = (req: Request, res: Response) => { + const body = CheckoutRequest.safeParse(req.body); + if (!body.success) { + return res.status(400).json({ + error: "INVALID_TYPE", + detail: body.error.format(), + }); + } + + const cart = body.data; + const { email, items } = body.data; + if (!email) { + return res.status(400).json({ + error: "BAD_REQUEST", + detail: "Missing billing email", + }); + } + if (!items.length) { + return res.status(400).json({ + error: "BAD_REQUEST", + detail: "Empty cart", + }); + } + + const orderID = uuidv4(); + const orderTime = new Date(); + const expiryTimeMillis = orderTime.getTime() + ORDER_EXPIRY_TIME; + const expiryTime = new Date(expiryTimeMillis); + + getProducts() + .then((products: Product[]): [Product[], PricedCart] => { + // TODO: Fetch promotion. + console.log("calculating prices prices"); + return [products, calculatePricing(products, cart, undefined)]; + }) + .then(([products, cart]) => + Promise.all([ + cart, + stripe.paymentIntents.create({ + payment_method_types: ["paynow"], + payment_method_data: { type: "paynow" }, + amount: cart.total, + currency: "sgd", + receipt_email: email, + description: `SCSE Merch Purchase:\n${describeCart( + products, + cart, + orderID + )}`, + }), + ]) + ) + .then(([cart, stripeIntent]) => { + console.log("creating order"); + const transactionID = stripeIntent.id; + const orderItems = cart.items.map( + (item): OrderItem => ({ + id: item.id, + name: item.name, + color: item.color, + size: item.size, + quantity: item.quantity, + price: item.discountedPrice, + }) + ); + // const reserved = cart.items.map( + // (item): ReservedProduct => ({ + // id: item.id, + // quantity: item.quantity, + // }) + // ); + const order: Order = { + id: orderID, + items: orderItems, + transaction_id: transactionID, + transaction_time: orderTime.toISOString(), + payment_method: "stripe", + customer_email: email, + status: OrderStatus.PENDING_PAYMENT, + }; + // const orderHold: OrderHold = { + // transaction_id: transactionID, + // expiry: expiryTime.toISOString(), + // reserved_products: reserved, + // }; + + // TODO: fix and uncomment stock increment + hold order + + // const stockIncrements = cart.items.map((item) => + // incrementStockCount(item.id, -item.quantity, item.size, item.color) + // ); + + return Promise.all([ + createOrder(order), + stripeIntent, + // createOrderHoldEntry(orderHold), + // ...stockIncrements, + ]); + }) + .then(([order, stripeIntent]) => { + console.log("order created"); + res.json({ + ...order, + expiry: expiryTime.toISOString(), + price: { + grandTotal: stripeIntent.amount, + }, + payment: { + method: "stripe", + clientSecret: stripeIntent.client_secret ?? "", + }, + }); + }) + .catch((e) => { + if (e instanceof PricingError) { + return res.status(400).json({ + error: "INVALID_REQUEST", + detail: e.message, + }); + } + console.warn(e); + return res.status(500).json({ error: "INTERNAL_SERVER_ERROR" }); + }); +}; diff --git a/apps/merch/src/routes/index.ts b/apps/merch/src/routes/index.ts index 29eb613f..cd694ddd 100644 --- a/apps/merch/src/routes/index.ts +++ b/apps/merch/src/routes/index.ts @@ -1,9 +1,15 @@ -import { Router } from "express" +import { Request, Response } from "../lib/types"; -const router = Router() +const genericInfo = { + github: "https://github.com/ntuscse/website", + website: "https://ntuscse.com", + service: "Merch", +}; -router.get("/", (req, res) => { - res.json({ content: "Hello World" }); -}) +export const index = (req: Request, res: Response) => { + res.json(genericInfo); +}; -export default router +export const notFound = (req: Request, res: Response) => { + res.status(404).json({ error: "NOT_FOUND" }); +}; diff --git a/apps/merch/src/routes/orders.ts b/apps/merch/src/routes/orders.ts new file mode 100644 index 00000000..2f4f8b6a --- /dev/null +++ b/apps/merch/src/routes/orders.ts @@ -0,0 +1,33 @@ +import { Order } from "types"; +import { getOrder, NotFoundError } from "../db"; +import { Request, Response } from "../lib/types"; + +export const orderGet = (req: Request<"id">, res: Response) => { + getOrder(req.params.id) + .then((order: Order) => { + res.json(censorDetails(order)); + }) + .catch((e) => { + if (e instanceof NotFoundError) { + return res.status(404).json({ error: "NOT_FOUND" }); + } + console.warn(e); + res.status(500).json({ error: "INTERNAL_SERVER_ERROR" }); + }); +}; + +const censorDetails = (order: Order): Order => { + const customerEmail = order.customer_email.split("@"); + return { + ...order, + customer_email: starCensor(customerEmail[0]) + "@" + customerEmail.slice(1).join("@"), + transaction_id: starCensor(order.transaction_id), + }; +}; + +const starCensor = (text: string, lettersToKeep = 3): string => { + if (text.length < lettersToKeep) { + return text; + } + return text.substring(0, lettersToKeep) + "*".repeat(text.length - 3); +}; diff --git a/apps/merch/src/routes/products.ts b/apps/merch/src/routes/products.ts index 28e548db..d14f2378 100644 --- a/apps/merch/src/routes/products.ts +++ b/apps/merch/src/routes/products.ts @@ -1,17 +1,31 @@ -import { Router } from "express"; -import { Product } from "types"; +import { Product, ProductsResponse } from "types"; +import { getProduct, getProducts, NotFoundError } from "../db"; +import { Request, Response } from "../lib/types"; -const router = Router(); +export const productsAll = (req: Request, res: Response) => { + getProducts() + .then((products: Product[]) => { + res.json({ products }); + }) + .catch((e) => { + if (e instanceof NotFoundError) { + return res.status(404).json({ error: "NOT_FOUND" }); + } + console.warn(e); + res.status(500).json({ error: "INTERNAL_SERVER_ERROR" }); + }); +}; -const products: Product[] = [ - { - id: "1", - name: "Sweater", - }, -]; - -router.get("/products", (req, res) => { - res.json({ products }); -}); - -export default router; +export const productGet = (req: Request<"id">, res: Response) => { + getProduct(req.params.id) + .then((product: Product) => { + res.json(product); + }) + .catch((e) => { + if (e instanceof NotFoundError) { + return res.status(404).json({ error: "NOT_FOUND" }); + } + console.warn(e); + res.status(500).json({ error: "INTERNAL_SERVER_ERROR" }); + }); +}; diff --git a/apps/merch/src/trpc/lib.ts b/apps/merch/src/trpc/lib.ts new file mode 100644 index 00000000..635600c1 --- /dev/null +++ b/apps/merch/src/trpc/lib.ts @@ -0,0 +1,16 @@ +import { inferAsyncReturnType, initTRPC } from "@trpc/server"; +import * as trpcExpress from "@trpc/server/adapters/express"; + +export const createContext = ({ req, res }: trpcExpress.CreateExpressContextOptions) => { + return { req, res } +}; // no context + +type Context = inferAsyncReturnType; + +const t = initTRPC.context().create(); // todo: add context to initTRPC + +export const router = t.router; +export const middleware = t.middleware; +export const publicProcedure = t.procedure; + +export const mergeRouters = t.mergeRouters; diff --git a/apps/merch/src/trpc/products/index.ts b/apps/merch/src/trpc/products/index.ts new file mode 100644 index 00000000..30d44776 --- /dev/null +++ b/apps/merch/src/trpc/products/index.ts @@ -0,0 +1,20 @@ +import { publicProcedure, router } from "../lib"; +import { z } from "zod"; +import { getProduct, getProducts } from "../../db"; + +export const productsRouter = router({ + getProducts: publicProcedure.query(async () => { + // retrieve products from database + const products = await getProducts(); + return { products }; + }), + getProduct: publicProcedure + .input( + z.object({ + id: z.string().nonempty("id"), + }) + ) + .query(({ input }) => { + return getProduct(input.id); + }), +}); diff --git a/apps/merch/src/trpc/router.ts b/apps/merch/src/trpc/router.ts new file mode 100644 index 00000000..4ad7e1ea --- /dev/null +++ b/apps/merch/src/trpc/router.ts @@ -0,0 +1,17 @@ +import { mergeRouters, publicProcedure, router } from "./lib"; +import * as trpcExpress from "@trpc/server/adapters/express"; +import { createContext } from "./lib"; +import { productsRouter } from "./products"; + +const greetingRouter = router({ + greeting: publicProcedure.query(() => "hello tRPC v10!"), +}); + +export const appRouter = mergeRouters(greetingRouter, productsRouter); + +export type AppRouter = typeof appRouter; + +export const trpcMiddleware = trpcExpress.createExpressMiddleware({ + router: appRouter, + createContext, +}); diff --git a/apps/merch/tsconfig.json b/apps/merch/tsconfig.json index 59b0be75..6b507c40 100644 --- a/apps/merch/tsconfig.json +++ b/apps/merch/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "tsconfig/node.json", "compilerOptions": { + "composite": true, + "experimentalDecorators": true, "target": "es5", "lib": [ "esnext" @@ -9,8 +11,10 @@ "esModuleInterop": true, "skipLibCheck": true, "outDir": "./dist", - "rootDir": "./src", - "allowSyntheticDefaultImports": true + "rootDir": ".", + "allowSyntheticDefaultImports": true, + "strict": true, + "resolveJsonModule": true }, "include": [ "src" @@ -18,9 +22,9 @@ "exclude": [ "node_modules", "dist", - "build", + "build" ], "ts-node": { "transpileOnly": true - }, + } } diff --git a/apps/merch/tsoa.json b/apps/merch/tsoa.json new file mode 100644 index 00000000..043ca2eb --- /dev/null +++ b/apps/merch/tsoa.json @@ -0,0 +1,12 @@ +{ + "entryFile": "src/index.ts", + "noImplicitAdditionalProperties": "throw-on-extras", + "controllerPathGlobs": ["src/**/*Controller.ts"], + "spec": { + "outputDirectory": "dist", + "specVersion": 3 + }, + "routes": { + "routesDir": "dist" + } +} diff --git a/apps/web/.env.example b/apps/web/.env.example index b8495e7b..4bdda633 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1 +1,3 @@ WORDPRESS_API_URL= +NEXT_PUBLIC_MERCH_API_ORIGIN= +NEXT_PUBLIC_FRONTEND_URL='https://clubs.ntu.edu.sg/csec/' diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js index 31799235..148f531c 100644 --- a/apps/web/.eslintrc.js +++ b/apps/web/.eslintrc.js @@ -1,12 +1,15 @@ module.exports = { root: true, extends: ["custom"], + parserOptions: { + project: ["./tsconfig.json"], + }, rules: { "no-restricted-imports": [ "error", { - "patterns": ["@features/*/*"] - } - ] + patterns: ["@features/*/*"], + }, + ], }, }; diff --git a/apps/web/features/layout/components/WebLayout.tsx b/apps/web/features/layout/components/WebLayout.tsx index e4ae582f..507c4284 100644 --- a/apps/web/features/layout/components/WebLayout.tsx +++ b/apps/web/features/layout/components/WebLayout.tsx @@ -3,7 +3,7 @@ import { FooterProps, Layout, NavBarProps } from "ui"; import { FaGithub, FaInstagram, FaLinkedin } from "react-icons/fa"; interface WebLayoutProps { - children: React.ReactNode + children: React.ReactNode; } export const WebLayout = ({ children }: WebLayoutProps) => { @@ -15,6 +15,7 @@ export const WebLayout = ({ children }: WebLayoutProps) => { { label: "Academics", href: "/academics" }, { label: "Learn", href: "/learn" }, { label: "Sponsors", href: "/sponsors" }, + { label: "Merch", href: "/merch" }, ], logoProps: { src: "/scse-logo/scse-logo-blue.png", @@ -61,5 +62,5 @@ export const WebLayout = ({ children }: WebLayoutProps) => { {children} - ) + ); }; diff --git a/apps/web/features/merch/components/checkout/Skeleton.tsx b/apps/web/features/merch/components/checkout/Skeleton.tsx new file mode 100644 index 00000000..40621aa9 --- /dev/null +++ b/apps/web/features/merch/components/checkout/Skeleton.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { + Grid, + Skeleton, + SkeletonText, + GridItem, + Box, + Flex, +} from "@chakra-ui/react"; + +const ItemSkeleton: React.FC = () => { + return ( + + + + + ); +}; +const CheckoutSkeleton: React.FC = () => { + return ( + + + + + + + + + + + + + + + + + + + ); +}; + +export default CheckoutSkeleton; diff --git a/apps/web/features/merch/components/checkout/StripeForm.tsx b/apps/web/features/merch/components/checkout/StripeForm.tsx new file mode 100644 index 00000000..2912ac18 --- /dev/null +++ b/apps/web/features/merch/components/checkout/StripeForm.tsx @@ -0,0 +1,103 @@ +import React, { useState } from "react"; +import { useRouter } from "next/router"; +import { + Elements, + PaymentElement, + useStripe, + useElements, +} from "@stripe/react-stripe-js"; +import { loadStripe } from "@stripe/stripe-js"; +import { Button, useToast } from "@chakra-ui/react"; +import { CartAction, CartActionType, useCartStore } from "../../context/cart"; +import { useCheckoutStore } from "../../context/checkout"; +import { routes } from "@/features/merch/constants"; + +type StripeFormProps = { + clientSecret: string; +}; +// Make sure to call `loadStripe` outside of a component’s render to avoid +// recreating the `Stripe` object on every render. +const stripePromise = loadStripe( + `${process.env.NEXT_PUBLIC_PUBLISHABLE_STRIPE_KEY ?? ""}` +); + +const PaymentForm = () => { + const router = useRouter(); + const stripe = useStripe(); + const elements = useElements(); + const cartContext = useCartStore(); + const { dispatch: cartDispatch, state: cartState } = cartContext; + const [isLoading, setIsLoading] = useState(false); + const toast = useToast(); + + const { state: checkoutState } = useCheckoutStore(); + + const handleSubmit = async (event: React.FormEvent) => { + // We don't want to let default form submission happen here, + // which would refresh the page. + event.preventDefault(); + // Stripe.js has not yet loaded. + if (!stripe || !elements) return; + + setIsLoading(true); + const result = await stripe.confirmPayment({ + elements, + redirect: "if_required", + confirmParams: { + receipt_email: cartState.billingEmail, + }, + }); + setIsLoading(false); + if (result.error) { + // Show error to your customer (for example, payment details incomplete) + toast({ + title: "Error", + description: result?.error.message, + status: "error", + isClosable: true, + }); + } else { + // TODO: CURRENTLY HARDCODED ONLY 1 ORDER, CHANGE IN THE FUTURE WHEN BACKEND IMPLEMENTED + // TODO: MIGRATE INTO HELPER FUNCTION UNDER API 'setOrderPaymentSucess' + // TODO: remove userId as we do not have a login + // TODO: order ID to be generated iteratively with api call + setIsLoading(true); + + cartDispatch({ + type: CartActionType.RESET_CART, + }); + setIsLoading(false); + if (typeof checkoutState?.id === "string") { + await router.push(`${routes.ORDERS}/${checkoutState?.id}`); + } else { + // TODO: handle missing id + } + } + }; + return ( +
+ + + + ); +}; + +const StripeForm: React.FC = (props) => { + const { clientSecret } = props; + + return ( + + + + ); +}; + +export default StripeForm; diff --git a/apps/web/features/merch/constants/index.tsx b/apps/web/features/merch/constants/index.tsx new file mode 100644 index 00000000..2f581e88 --- /dev/null +++ b/apps/web/features/merch/constants/index.tsx @@ -0,0 +1,2 @@ +export * from "./queryKeys" +export * from "./routes" \ No newline at end of file diff --git a/apps/web/features/merch/constants/queryKeys.ts b/apps/web/features/merch/constants/queryKeys.ts new file mode 100644 index 00000000..030a6f70 --- /dev/null +++ b/apps/web/features/merch/constants/queryKeys.ts @@ -0,0 +1,9 @@ +export enum QueryKeys { + PRODUCTS = "PRODUCTS", + PRODUCT = "PRODUCT", + VOUCHER = "VOUCHER", + ORDER = "ORDER", + ORDERS = "ORDERS", + EMAIL = "EMAIL", + CHECKOUT = "CHECKOUT", +} diff --git a/apps/web/features/merch/constants/routes.ts b/apps/web/features/merch/constants/routes.ts new file mode 100644 index 00000000..d811e090 --- /dev/null +++ b/apps/web/features/merch/constants/routes.ts @@ -0,0 +1,17 @@ +type Routes = { + HOME: string; + PRODUCT: string; + CART: string; + CHECKOUT: string; + ORDERS: string; +}; + +export const routes: Routes = { + HOME: "/merch", + PRODUCT: "/merch/product", + CART: "/merch/cart", + CHECKOUT: "/merch/checkout", + ORDERS: "/merch/orders", +}; + +export default routes; diff --git a/apps/web/features/merch/context/cart/index.tsx b/apps/web/features/merch/context/cart/index.tsx new file mode 100644 index 00000000..ccde06ec --- /dev/null +++ b/apps/web/features/merch/context/cart/index.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useReducer, useMemo, useContext } from "react"; +import { CartState, CartItem } from "types/lib/merch"; + +type ContextType = { + state: CartState; + dispatch: React.Dispatch; +} | null; + +export enum CartActionType { + RESET_CART = "RESET_CART", + INITIALIZE = "initialize", + ADD_ITEM = "add_item", + UPDATE_QUANTITY = "update_quantity", + REMOVE_ITEM = "remove_item", + VALID_VOUCHER = "valid_voucher", + REMOVE_VOUCHER = "remove_voucher", + UPDATE_NAME = "update_name", + UPDATE_BILLING_EMAIL = "update_billing_email", +} + +export type CartAction = + | { type: CartActionType.RESET_CART } + | { type: CartActionType.INITIALIZE; payload: CartState } + | { type: CartActionType.ADD_ITEM; payload: CartItem } + | { type: CartActionType.UPDATE_QUANTITY; payload: CartItem } + | { + type: CartActionType.REMOVE_ITEM; + payload: { id: string; size: string; color: string }; + } + | { type: CartActionType.VALID_VOUCHER; payload: string } + | { type: CartActionType.REMOVE_VOUCHER; payload: null } + | { type: CartActionType.UPDATE_NAME; payload: string } + | { type: CartActionType.UPDATE_BILLING_EMAIL; payload: string }; + +const CartContext = React.createContext(null); + +const initState: CartState = { + cart: { + items: [], + }, + voucher: "", + name: "", + billingEmail: "", +}; + +export const cartReducer = ( + state: CartState, + action: CartAction +): CartState => { + switch (action.type) { + case CartActionType.RESET_CART: { + return initState; + } + case CartActionType.INITIALIZE: { + return { ...state, ...action.payload }; + } + case CartActionType.ADD_ITEM: { + // Find if there's an existing item already: + const { id, size, color, quantity } = action.payload; + const idx = state.cart.items.findIndex( + (x) => x.id === id && x.size === size && x.color === color + ); + const newQuantity = Math.min( + (state?.cart?.items[idx]?.quantity ?? 0) + quantity, + 99 + ); + return { + ...state, + cart: { + ...state.cart, + items: + idx === -1 + ? [...state.cart.items, action.payload] + : [ + ...state.cart.items.slice(0, idx), + { ...state.cart.items[idx], quantity: newQuantity }, + ...state.cart.items.slice(idx + 1), + ], + }, + }; + } + + case CartActionType.UPDATE_QUANTITY: { + const { id, size, color, quantity } = action.payload; + const idx = state.cart.items.findIndex( + (x) => x.id === id && x.size === size && x.color === color + ); + return { + ...state, + cart: { + ...state.cart, + items: + idx === -1 + ? [...state.cart.items] + : [ + ...state.cart.items.slice(0, idx), + { ...state.cart.items[idx], quantity }, + ...state.cart.items.slice(idx + 1), + ], + }, + }; + } + case CartActionType.REMOVE_ITEM: { + const { id, size, color } = action.payload; + return { + ...state, + cart: { + ...state.cart, + items: [ + ...state.cart.items.filter( + (x) => !(x.id === id && x.size === size && x.color == color) + ), + ], + }, + }; + } + + case CartActionType.VALID_VOUCHER: { + return { ...state, voucher: action.payload }; + } + + case CartActionType.REMOVE_VOUCHER: { + return { ...state, voucher: "" }; + } + + case CartActionType.UPDATE_NAME: { + return { ...state, name: action.payload }; + } + + case CartActionType.UPDATE_BILLING_EMAIL: { + return { ...state, billingEmail: action.payload }; + } + + default: { + throw new Error(`Unhandled action type - ${JSON.stringify(action)}`); + } + } +}; + +export const useCartStore = () => { + const context = useContext(CartContext); + if (context === null) { + throw new Error("useCartStore must be used within a CartProvider."); + } + return context; +}; + +interface CartProviderProps { + children: React.ReactNode; +} + +export const CartProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(cartReducer, initState); + const value = useMemo(() => ({ state, dispatch }), [state]); + + useEffect(() => { + const storedCartData: CartState = JSON.parse( + localStorage.getItem("cart") as string + ) as typeof initState; + if (storedCartData) { + const cartState: CartState = JSON.parse( + JSON.stringify(initState) + ) as typeof initState; + cartState.cart.items = storedCartData.cart?.items; + cartState.name = storedCartData.name; + cartState.billingEmail = storedCartData.billingEmail; + dispatch({ type: CartActionType.INITIALIZE, payload: cartState }); + } + }, []); + useEffect(() => { + if (state === initState) return; + localStorage.setItem("cart", JSON.stringify(state)); + }, [state]); + + return {children}; +}; diff --git a/apps/web/features/merch/context/checkout/index.tsx b/apps/web/features/merch/context/checkout/index.tsx new file mode 100644 index 00000000..9913b30b --- /dev/null +++ b/apps/web/features/merch/context/checkout/index.tsx @@ -0,0 +1,42 @@ +import React, { useContext, useMemo, useState } from "react"; +import { CheckoutResponse } from "types"; + +type ContextType = { + state: CheckoutResponse | null; + setState: React.Dispatch>; +} | null; + +const CheckoutContext = React.createContext(null); + +export const useCheckoutStore = () => { + const context = useContext(CheckoutContext); + if (context === null) { + throw new Error("useCheckoutStore must be used within a CheckoutProvider."); + } + return context; +}; + +interface CheckoutProviderProps { + children: React.ReactNode; +} + +export const CheckoutProvider: React.FC = ({ + children, +}) => { + const [checkoutState, setCheckoutState] = useState( + null + ); + + const value = useMemo( + () => ({ + state: checkoutState, + setState: setCheckoutState, + }), + [checkoutState] + ); + return ( + + {children} + + ); +}; diff --git a/apps/web/features/merch/functions/cart.ts b/apps/web/features/merch/functions/cart.ts new file mode 100644 index 00000000..ae88fad2 --- /dev/null +++ b/apps/web/features/merch/functions/cart.ts @@ -0,0 +1,30 @@ +import { CartItem } from "types/lib/merch"; + +export const getQtyInCart = ( + cartItems: CartItem[], + id: string, + color: string, + size: string +): number => { + const cartItem = cartItems.find((item) => { + return item.id === id && item.size === size && item.color === color; + }); + + if (cartItem) { + return cartItem.quantity; + } + return 0; +}; + +export const displayQtyInCart = ( + cartItems: CartItem[], + id: string, + color: string, + size: string +): string => { + const qty = getQtyInCart(cartItems, id, color, size); + if (qty > 0) { + return `You have already added ${qty} to your cart.`; + } + return ""; +}; diff --git a/apps/web/features/merch/functions/currency.ts b/apps/web/features/merch/functions/currency.ts new file mode 100644 index 00000000..c33847dc --- /dev/null +++ b/apps/web/features/merch/functions/currency.ts @@ -0,0 +1,7 @@ +export const displayPrice = (amountInCents: number): string => { + const amountStr = amountInCents.toString(); + const dollarStr = amountStr.slice(0, -2).padStart(1, "0"); + const centStr = amountStr.slice(-2).padStart(2, "0"); + const priceStr = dollarStr.concat(".").concat(centStr); + return `$${priceStr}`; +}; diff --git a/apps/web/features/merch/functions/index.tsx b/apps/web/features/merch/functions/index.tsx new file mode 100644 index 00000000..317c01db --- /dev/null +++ b/apps/web/features/merch/functions/index.tsx @@ -0,0 +1,3 @@ +export * from "./cart" +export * from "./currency" +export * from "./stock" diff --git a/apps/web/features/merch/functions/stock.ts b/apps/web/features/merch/functions/stock.ts new file mode 100644 index 00000000..68d2e206 --- /dev/null +++ b/apps/web/features/merch/functions/stock.ts @@ -0,0 +1,97 @@ +import { Product } from "types/lib/merch"; + +export const getQtyInStock = ( + product: Product, + color: string, + size: string +): number => { + // returns remaining stock for specified color and size + if (product.stock[color] && product.stock[color][size]) { + return product.stock[color][size]; + } + return 0; +}; + +export const displayStock = ( + product: Product, + color: string, + size: string +): string => { + // returns string describing remaining stock + if (product.stock[color] && product.stock[color][size]) { + const qty = product.stock[color][size]; + if (qty > 0) { + return `${qty} available`; + } + return "OUT OF STOCK"; + } + + return "ERROR: invalid color/size selected"; +}; + +export const isOutOfStock = (product: Product): boolean => { + // returns true if product is out of stock in all colors and sizes + if (product && product.stock) { + const totalQty = Object.values(product.stock).reduce( + (acc, stockByColor) => { + const colorQty = Object.values(stockByColor).reduce( + (acc2, qty) => acc2 + qty, + 0 + ); + return acc + colorQty; + }, + 0 + ); + return totalQty <= 0; + } else { + return false; + } +}; + +export const isColorAvailable = (product: Product, color: string): boolean => { + // returns true if color is available in any size + // returns false if color is out of stock in all sizes + const colorStock = Object.values(product.stock[color]).reduce( + (acc, size) => acc + size, + 0 + ); + return colorStock > 0; +}; + +export const isSizeAvailable = (product: Product, size: string): boolean => { + // returns true if size is available in any color + // returns false if size is out of stock in all colors + const sizeStock = Object.values(product.stock).map((d) => d[size] || 0); + const totalQty = sizeStock.reduce((a, b) => { + return a + b; + }, 0); + return totalQty > 0; +}; + +export const getDefaultSize = (product: Product): string | null => { + if (!product.sizes) return null; + const index1 = product.sizes.findIndex((size) => + isSizeAvailable(product, size) + ); + const index2 = product.sizes.findIndex( + (size, idx) => idx > index1 && isSizeAvailable(product, size) + ); + if (index1 !== -1 && index2 === -1) { + return product.sizes[index1]; // only 1 size available + } + return null; +}; + +export const getDefaultColor = (product: Product): string | null => { + if (!product.colors) return null; + const index1 = product.colors.findIndex((color) => + isColorAvailable(product, color) + ); + const index2 = product.colors.findIndex( + (color, idx) => idx > index1 && isColorAvailable(product, color) + ); + if (index1 !== -1 && index2 === -1) { + return product.colors[index1]; // only 1 color available + } + return null; +}; diff --git a/apps/web/features/merch/services/api.ts b/apps/web/features/merch/services/api.ts new file mode 100644 index 00000000..1b0fd34e --- /dev/null +++ b/apps/web/features/merch/services/api.ts @@ -0,0 +1,90 @@ +import { + APIError, + Cart, + CheckoutRequest, + CheckoutResponse, + PricedCart, + Product, + ProductsResponse, + QuotationRequest, +} from "types"; + +export class Api { + private API_ORIGIN: string; + + constructor() { + if (!process.env.NEXT_PUBLIC_MERCH_API_ORIGIN) { + throw new Error( + "NEXT_PUBLIC_MERCH_API_ORIGIN environment variable is not set" + ); + } + this.API_ORIGIN = process.env.NEXT_PUBLIC_MERCH_API_ORIGIN; + } + + // http methods + async get(urlPath: string): Promise { + const response = await fetch(`${this.API_ORIGIN}${urlPath}`); + type responseType = T | APIError; + const resp = (await response.json()) as responseType; + if ("error" in resp) { + console.error("Server error:", resp); + throw new Error(`Error ${resp.error}`); + } + return resp; + } + + async post(urlPath: string, data: R): Promise { + const response = await fetch(`${this.API_ORIGIN}${urlPath}`, { + method: "POST", + mode: "cors", + cache: "no-cache", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + }, + redirect: "follow", + referrerPolicy: "no-referrer", + body: JSON.stringify(data), + }); + type responseType = T | APIError; + const resp = (await response.json()) as responseType; + if ("error" in resp) { + console.error("Server error:", resp); + throw new Error(`Error ${resp.error}`); + } + return resp; + } + + async getProducts(): Promise { + const res = await this.get("/products"); + return res.products; + } + + async getProduct(productId: string) { + const res = await this.get(`/products/${productId}`); + console.log("product res", res); + return res; + } + + async getOrder(orderID: string): Promise { + if (!orderID) { + throw new Error("No order ID"); + } + const res = await this.get(`/orders/${orderID}`); + return res; + } + + async postCheckoutCart( + cart: Cart, + email: string, + promoCode?: string + ): Promise { + return await this.post(`/checkout`, { + ...cart, + promoCode: promoCode, + email, + }); + } +} + +export const api = new Api(); diff --git a/apps/web/features/merch/services/api.tsx b/apps/web/features/merch/services/api.tsx new file mode 100644 index 00000000..bfac534c --- /dev/null +++ b/apps/web/features/merch/services/api.tsx @@ -0,0 +1,117 @@ +import { Product } from "types/lib/merch"; + +export class Api { + private API_ORIGIN: string; + + constructor() { + if (!process.env.NEXT_PUBLIC_MERCH_API_ORIGIN) { + throw new Error("NEXT_PUBLIC_MERCH_API_ORIGIN environment variable is not set") + } + this.API_ORIGIN = process.env.NEXT_PUBLIC_MERCH_API_ORIGIN || ""; + } + + // http methods + async get(urlPath: string): Promise> { + const response = await fetch(`${this.API_ORIGIN}${urlPath}`); + const convert = response.json() as unknown; // Convert to unknown type + return convert as Record + } + + /* + // eslint-disable-next-line class-methods-use-this + async post(urlPath: string, data: any): Promise { + const response = await fetch(`${this.API_ORIGIN}${urlPath}`, { + method: "POST", // *GET, POST, PUT, DELETE, etc. + mode: "cors", // no-cors, *cors, same-origin + cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached + credentials: "same-origin", // include, *same-origin, omit + headers: { + "Content-Type": "application/json", + // 'Content-Type': 'application/x-www-form-urlencoded', + }, + redirect: "follow", // manual, *follow, error + referrerPolicy: "no-referrer", // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url + body: JSON.stringify(data), // body data type must match + }); + return response.json(); + } + */ + + // eslint-disable-next-line class-methods-use-this + async getProducts(): Promise { + try { + const res = await this.get("/products"); + console.log("product-list", res); + return res?.products ?? []; + } catch (e) { + if(e instanceof Error){ + throw new Error(e.message); + } + return [] + } + } + + // eslint-disable-next-line class-methods-use-this + async getProduct(productId: string) { + try { + const res = await this.get(`/products/${productId}`); + console.log("product res", res); + return res; + } catch (e: any) { + throw new Error(e); + } + } + + /* + async getOrder(userId: string, orderId: string) { + try { + const res = await this.get(`/orders/${orderId}`); + console.log("Order Summary response:", res); + return res; + } catch (e: any) { + throw new Error(e); + } + } + + async getOrderHistory(userId: string) { + try { + const res = await this.get(`/orders/${userId}`); + console.log("Order Summary response:", res); + return res.json(); + } catch (e: any) { + throw new Error(e); + } + } + + async postCheckoutCart( + items: CartItemType[], + email: string, + promoCode: string | null + ) { + try { + const res = await this.post(`/cart/checkout`, { + items, + promoCode: promoCode ?? "", + email, + }); + return res; + } catch (e: any) { + throw new Error(e); + } + } + + async postQuotation(items: CartItemType[], promoCode: string | null) { + try { + const res = await this.post(`/cart/quotation`, { + items, + promoCode: promoCode ?? "", + }); + return res; + } catch (e: any) { + throw new Error(e); + } + } + */ +} + +export const api = new Api(); diff --git a/apps/web/next.config.js b/apps/web/next.config.js index f22563e4..d9111bb6 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -2,6 +2,7 @@ const nextConfig = { reactStrictMode: true, swcMinify: true, + transpilePackages: ['ui', 'merch-helpers'], images: { remotePatterns: [ { @@ -14,9 +15,18 @@ const nextConfig = { hostname: "clubs.ntu.edu.sg", pathname: "/csec/**", }, + { + protocol: "https", + hostname: "cdn.ntuscse.com", + pathname: "/merch/products/images/**", + }, + { + protocol:"https", + hostname: "api.qrserver.com", + pathname: "/merch/order/**" + } ], }, - transpilePackages: ["ui"], }; module.exports = nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index af82b560..e7d53529 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,6 +5,8 @@ "scripts": { "dev": "next dev -p 3001", "build": "next build", + "build:ci": "yarn run build", + "vercel-build": "echo 'skipping fresh build'", "start": "next start -p 3001", "lint": "next lint", "lint:fix": "next lint --fix", @@ -20,13 +22,23 @@ "@chakra-ui/system": "^2.3.1", "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", + "@stripe/react-stripe-js": "^2.1.1", + "@stripe/stripe-js": "^1.54.0", + "@tanstack/react-query": "^4.29.15", + "@tanstack/react-query-devtools": "^4.26.1", + "@trpc/client": "^10.31.0", + "@trpc/next": "^10.31.0", + "@trpc/react-query": "^10.31.0", "framer-motion": "^7.6.4", - "next": "13.4.6", + "merch-helpers": "*", + "next": "13.4.9", "react": "18.2.0", "react-bootstrap": "^2.5.0", "react-dom": "18.2.0", "react-icons": "^4.8.0", - "ui": "*" + "swiper": "^9.4.0", + "ui": "*", + "zod": "^3.21.4" }, "devDependencies": { "@testing-library/jest-dom": "^5.16.5", diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index 4312a164..b26cacb7 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -1,5 +1,7 @@ import "../styles/globals.css"; -import type { AppProps } from "next/app"; +import type { AppProps, AppType } from "next/app"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ChakraProvider } from "@chakra-ui/react"; import { theme } from "ui/theme"; import "@fontsource/work-sans/300.css"; @@ -9,15 +11,29 @@ import "@fontsource/work-sans/700.css"; import "ui/fonts/styles.css"; // for custom fonts not available on @fontsource import { WebLayout } from "@/features/layout"; +import { CartProvider } from "@/features/merch/context/cart"; +import { CheckoutProvider } from "@/features/merch/context/checkout"; -const App = ({ Component, pageProps }: AppProps) => { +const queryClient = new QueryClient({ + defaultOptions: { queries: { refetchOnWindowFocus: false } }, +}); + +const App: AppType = ({ Component, pageProps }: AppProps) => { return ( - - - - - + + + + + + + + + + + + ); }; +// eslint-disable-next-line @typescript-eslint/no-unsafe-call export default App; diff --git a/apps/web/pages/merch/cart/index.tsx b/apps/web/pages/merch/cart/index.tsx new file mode 100644 index 00000000..e8354575 --- /dev/null +++ b/apps/web/pages/merch/cart/index.tsx @@ -0,0 +1,377 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ + +import React, { useRef, useState, FC, useEffect } from "react"; +import Link from "next/link"; +import { + Button, + Flex, + Heading, + useBreakpointValue, + Divider, + useDisclosure, + Grid, + GridItem, + Text, + Input, + Spinner, +} from "@chakra-ui/react"; +import { useQuery } from "@tanstack/react-query"; +import Joi from "joi"; +import { + CartAction, + CartActionType, + useCartStore, +} from "features/merch/context/cart"; +import { + CartCard, + CartEmptyView, + CartHeader, + CartItemCard, + CartRemoveModal, + LoadingScreen, + Page, +} from "ui/components/merch"; +import { api } from "features/merch/services/api"; +import { routes, QueryKeys } from "features/merch/constants"; +import { displayPrice } from "features/merch/functions"; +import { calculatePricing } from "merch-helpers"; +import { useRouter } from "next/router"; + +type ValidationType = { + error: boolean; + isLoading: boolean; +}; + +const Cart: FC = () => { + // Context hook. + const cartContext = useCartStore(); + const { state: cartState, dispatch: cartDispatch } = cartContext; + + const router = useRouter(); + const [reroute, setReroute] = useState(false); + + // Email input for billing. + const [validation, setValidation] = useState({ + isLoading: false, + error: false, + }); + + // Calculation of pricing + const [isCartLoading, setIsCartLoading] = useState(true); + const { data: products, isLoading: isProductsQueryLoading } = useQuery( + [QueryKeys.PRODUCTS], + () => api.getProducts(), + { + onSuccess: () => { + setIsCartLoading(false); + }, + } + ); + + // Voucher section + // const [voucherInput, setVoucherInput] = useState(""); + // const [voucherError, setVoucherError] = useState(false); + + const pricedCart = products + ? calculatePricing(products, cartState.cart, undefined) + : null; + + const emailValidator = Joi.string() + .email({ tlds: { allow: false } }) + .required() + .label("Email"); + + // Removal Modal cartStates + const { isOpen, onOpen, onClose } = useDisclosure(); + const toBeRemoved = useRef({ productId: "", size: "", color: "" }); + + // Check if break point hit. + const isMobile: boolean = + useBreakpointValue({ base: true, md: false }) || false; + + // Apply voucher - TODO + // const { mutate: applyVoucher, isLoading: voucherLoading } = useMutation( + // () => api.postQuotation(cartState.cart, voucherInput), + // { + // onMutate: () => { + // setPriceLoading(true); + // }, + // onSuccess: (data: PricedCart) => { + // setPriceInfo(data.total); + // if (data.price.discount > 0) { + // // Voucher is valid + // cartDispatch({ type: CartActionType.VALID_VOUCHER, payload: voucherInput }); + // setVoucherError(false); + // setVoucherInput(""); + // } else { + // setVoucherError(true); + // } + // }, + // onSettled: () => { + // setPriceLoading(false); + // }, + // } + // ); + + // const handleRemoveVoucher = () => { + // setVoucherInput(""); + // cartDispatch({ type: CartActionType.REMOVE_VOUCHER, payload: null }); + // applyVoucher(); + // }; + + // Update Cart Item by Size & Id (To be changed next time: BE) + const removeItem = (productId: string, size: string, color: string) => { + cartDispatch({ + type: CartActionType.REMOVE_ITEM, + payload: { id: productId, size: size, color: color }, + }); + onClose(); + }; + + // Set modal's ref value to size & productId pair. + const handleRemoveItem = (productId: string, size: string, color: string) => { + onOpen(); + toBeRemoved.current.size = size; + toBeRemoved.current.color = color; + toBeRemoved.current.productId = productId; + }; + + // Update Cart Item by Size & Id (To be changed next time: BE) + const onQuantityChange = ( + productId: string, + size: string, + color: string, + qty: number + ) => { + const action: CartAction = { + type: CartActionType.UPDATE_QUANTITY, + payload: { id: productId, size: size, color: color, quantity: qty }, + }; + cartDispatch(action); + }; + + const handleToCheckout = async () => { + setValidation({ isLoading: true, error: false }); + try { + await emailValidator.validateAsync(cartState.billingEmail); + cartDispatch({ + type: CartActionType.UPDATE_BILLING_EMAIL, + payload: cartState.billingEmail, + }); + setReroute(true); + } catch (error: any) { + setValidation({ isLoading: false, error: true }); + } + }; + + const CartHeading = ( + + Your Cart + + ); + + const PriceInfoSection = ( + + {!pricedCart ? ( + + + Calculating your cart price + + ) : ( + <> + + + Item(s) subtotal + {displayPrice(pricedCart.subtotal)} + + + Voucher Discount + {displayPrice(pricedCart.discount)} + + + + Total + {displayPrice(pricedCart.total)} + + + + + { + cartDispatch({ + type: CartActionType.UPDATE_NAME, + payload: event.target.value, + }); + }} + variant="outline" + /> + + { + cartDispatch({ + type: CartActionType.UPDATE_BILLING_EMAIL, + payload: event.target.value, + }); + }} + variant="outline" + /> + + {validation.error && "*Invalid email format"} + + + + + + + + + + )} + + ); + /* TODO + const VoucherSection = ( + + + + ) => { + const target = e.target as HTMLInputElement; + setVoucherInput(target.value); + }} + /> + + + + {!cartState.voucher ? ( + Apply your voucher code! + ) : ( + + {voucherError && Invalid voucher} + {cartState.voucher && priceInfo.discount > 0 && ( + + Applied Voucher + + + )} + + )} + + + + ); +*/ + const renderCartView = () => ( + + + {!isMobile && } + {cartState.cart.items.map((item, index) => ( + <> + product.id === item.id)} + isLoading={isProductsQueryLoading} + isMobile={isMobile} + onRemove={handleRemoveItem} + onQuantityChange={onQuantityChange} + /> + {index !== cartState.cart.items.length - 1 && } + + ))} + + + {/* {VoucherSection} TODO*/} + {PriceInfoSection} + + + An email will be sent to you closer to the collection date. Our + collection venue is at 50 Nanyang Ave, #32 Block N4 #02a, Singapore + 639798. + + + + + removeItem( + toBeRemoved.current.productId, + toBeRemoved.current.size, + toBeRemoved.current.color + ) + } + /> + + ); + + const renderCartContent = () => { + if (isCartLoading) { + return ; + } + if (cartState.cart.items.length === 0) { + return ; + } + return renderCartView(); + }; + + useEffect(() => { + if (reroute) { + void router.push(routes.CHECKOUT); + } + }, [reroute]); + + return ( + + {CartHeading} + {renderCartContent()} + + ); +}; + +export default Cart; diff --git a/apps/web/pages/merch/checkout/index.tsx b/apps/web/pages/merch/checkout/index.tsx new file mode 100644 index 00000000..1ff4943e --- /dev/null +++ b/apps/web/pages/merch/checkout/index.tsx @@ -0,0 +1,188 @@ +import { CartEmptyView, Page } from "ui/components/merch"; +import { useCartStore } from "@/features/merch/context/cart"; +import { useEffect, useState } from "react"; +import { useCheckoutStore } from "@/features/merch/context/checkout"; +import { + Box, + Divider, + Flex, + Grid, + GridItem, + Heading, + Text, + Image, + Badge, +} from "@chakra-ui/react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { QueryKeys, routes } from "@/features/merch/constants"; +import { api } from "@/features/merch/services/api"; +import CheckoutSkeleton from "@/features/merch/components/checkout/Skeleton"; +import Link from "next/link"; +import { displayPrice } from "@/features/merch/functions"; +import { useRouter } from "next/router"; +import StripeForm from "@/features/merch/components/checkout/StripeForm"; +import { CheckoutResponse } from "types"; + +const CheckoutPage = () => { + const [isLoading, setIsLoading] = useState(true); + const { state: cartState } = useCartStore(); + const { setState: setCheckoutState } = useCheckoutStore(); + + // Fetch and check if cart item is valid. + const { mutate: initCheckout } = useMutation( + () => + api.postCheckoutCart( + cartState.cart, + cartState.billingEmail, + cartState.voucher + ), + { + retry: false, + onMutate: () => { + setIsLoading(true); + }, + onSuccess: (data: CheckoutResponse) => { + setCheckoutState(data); + }, + onSettled: () => { + setIsLoading(false); + }, + } + ); + + const router = useRouter(); + + useEffect(() => { + if (!cartState.billingEmail) { + void router.push(routes.CART); + return; + } + initCheckout(); + }, []); + + return ( + + + Checkout + + {isLoading ? ( + + ) : cartState.cart.items.length === 0 ? ( + + ) : ( + + )} + + ); +}; + +const CheckoutView = () => { + const { state: checkoutState } = useCheckoutStore(); + return ( + + + {OrderSummary()} + + + {checkoutState?.payment?.clientSecret && ( + + )} + + + ); +}; + +const OrderSummary = () => { + const { state: cartState } = useCartStore(); + const { state: checkoutState } = useCheckoutStore(); + + const noOfItems = cartState.cart.items.length; + + const { data: products } = useQuery( + [QueryKeys.PRODUCTS], + () => api.getProducts(), + {} + ); + + return ( + + + Order Summary + + {`${noOfItems} item(s) Edit`} + + + {`Name: ${cartState.name}`} + {`Billing email: ${cartState.billingEmail}`} + {cartState.cart.items?.map((item) => { + const product = products?.find(({ id }) => id === item.id); + const subtotal = (product?.price ?? -1) * item.quantity; + return ( + + {product?.name} + + + + {product?.name} + + {displayPrice(subtotal)} + + + {`Color: ${item.color}`} + + + + {`Qty x${item.quantity}`} + + {item.size} + + + {displayPrice(product?.price ?? 0)} each + + + + ); + })} + + + + + {/* Subtotal: */} + {/* Discount: */} + Grand total: + + + {/* {displayPrice(checkoutState?.price?.subtotal ?? 0)} */} + {/* {displayPrice(checkoutState?.price?.discount ?? 0)} */} + + {displayPrice(checkoutState?.price?.grandTotal ?? 0)} + + + + + ); +}; + +export default CheckoutPage; diff --git a/apps/web/pages/merch/index.tsx b/apps/web/pages/merch/index.tsx new file mode 100644 index 00000000..b47369d8 --- /dev/null +++ b/apps/web/pages/merch/index.tsx @@ -0,0 +1,92 @@ +import React, { useState } from "react"; +import { Flex, Divider, Select, Heading, Grid } from "@chakra-ui/react"; +import { useQuery } from "@tanstack/react-query"; +import { Card, MerchListSkeleton, Page } from "ui/components/merch"; +import { QueryKeys } from "features/merch/constants"; +import { api } from "features/merch/services/api"; +import { Product } from "types/lib/merch"; +import { isOutOfStock } from "features/merch/functions"; + +const MerchandiseList = () => { + const [selectedCategory, setSelectedCategory] = useState(""); + + const { data: products, isLoading } = useQuery( + [QueryKeys.PRODUCTS], + () => api.getProducts(), + {} + ); + + const categories = products?.map((product: Product) => product?.category); + const uniqueCategories = categories + ?.filter((c, idx) => categories.indexOf(c) === idx) + .filter(Boolean); + + const handleCategoryChange = ( + event: React.ChangeEvent + ) => { + setSelectedCategory(event.target.value); + }; + + return ( + + + + New Drop + + + + + {isLoading ? ( + + ) : ( + + {products + ?.filter((product: Product) => { + if (!product?.is_available) return false; + if (selectedCategory === "") return true; + return product?.category === selectedCategory; + }) + ?.map((item: Product, idx: number) => ( + + ))} + + )} + + ); +}; + +export default MerchandiseList; diff --git a/apps/web/pages/merch/orders/[slug].tsx b/apps/web/pages/merch/orders/[slug].tsx new file mode 100644 index 00000000..bf237002 --- /dev/null +++ b/apps/web/pages/merch/orders/[slug].tsx @@ -0,0 +1,109 @@ +import React, { useState } from "react"; +import { useRouter } from "next/router"; +import { Button, Divider, Flex, Heading, Text, useBreakpointValue } from "@chakra-ui/react"; +import { useQuery } from "@tanstack/react-query"; +import { Page } from "ui/components/merch"; +import { Order } from "types"; +import { api } from "features/merch/services/api"; +import { routes } from "features/merch/constants/routes"; +import { QueryKeys } from "features/merch/constants/queryKeys"; +import { displayPrice } from "features/merch/functions/currency"; +import Link from "next/link" +import LoadingScreen from "ui/components/merch/skeleton/LoadingScreen"; +import OrderItem from "ui/components/merch/OrderItem"; +import QRCode from "ui/components/merch/QRCode"; +import OrderNumber from "ui/components/merch/OrderNumber"; +const OrderSummary: React.FC = () => { +// Check if break point hit. KIV + const isMobile: boolean = useBreakpointValue({ base: true, md: false }) || false; + const router = useRouter(); + const orderSlug = router.query.slug as string | undefined; + + const [showThankYou, setShowThankYou] = useState(false); + const [orderState, setOrderState] = useState(null); + // TODO: Fetch subtotal and total from server. + const [total, setTotal] = useState(0); + // Fetch and check if cart item is valid. Number(item.price) set to convert string to num + const { isLoading } = useQuery( + [QueryKeys.ORDER, orderSlug], + () => api.getOrder(orderSlug ?? ""), + { + enabled: !!orderSlug, + onSuccess: (data: Order) => { + setOrderState(data); + setTotal( + data.items.reduce((acc, item) => { + return item.price * item.quantity + acc; + }, 0) + ); + setShowThankYou(true); + }, + } + ); + + const renderThankYouMessage = () => ( + <> + THANK YOU + Thank you for your purchase. We have received your order. + + + + + + ); + const renderOrderSummary = () => ( + <> + + {showThankYou && renderThankYouMessage()} + + + + + + {/*{orderState?.items.map((item) => (*/} + {/* */} + {/*))}*/} + + {orderState? : Order Not Found} + + + + + Item Subtotal: + Voucher Discount: + Total: + + + {displayPrice(total)} + + {/*{displayPrice( TODO*/} + {/* (orderState?.billing?.subtotal ?? 0) -*/} + {/* (orderState?.billing?.total ?? 0)*/} + {/*)}*/} + 0 + + {displayPrice(total)} + + + + + + + ); + const renderSummaryPage = () => { + if (isLoading) return ; + //rmb to change this v + if (orderState === undefined || orderState === null){return ;} + return renderOrderSummary(); + }; + return {renderSummaryPage()}; +} +export default OrderSummary diff --git a/apps/web/pages/merch/product/[slug].tsx b/apps/web/pages/merch/product/[slug].tsx new file mode 100644 index 00000000..f320b1cd --- /dev/null +++ b/apps/web/pages/merch/product/[slug].tsx @@ -0,0 +1,474 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ + +import React, { useState } from "react"; +import { useRouter } from "next/router"; +import { + Badge, + Button, + Center, + Divider, + Flex, + Grid, + GridItem, + Heading, + Input, + Text, + useDisclosure, +} from "@chakra-ui/react"; +import { + EmptyProductView, + MerchCarousel, + MerchDetailSkeleton, + Page, + SizeChartDialog, + SizeOption, +} from "ui/components/merch"; +import { Product } from "types/lib/merch"; +import { + CartAction, + CartActionType, + useCartStore, +} from "features/merch/context/cart"; +import { QueryKeys, routes } from "features/merch/constants"; +import { + displayPrice, + displayQtyInCart, + displayStock, getDefaultColor, getDefaultSize, + getQtyInCart, + getQtyInStock, + isColorAvailable, + isOutOfStock, + isSizeAvailable, +} from "features/merch/functions"; +import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/features/merch/services/api"; + +interface GroupTitleProps { + children: React.ReactNode; +} + +const GroupTitle = ({ children }: GroupTitleProps) => ( + + {children} + +); + +const MerchDetail = (_props: InferGetStaticPropsType) => { + // Context hook. + const { state: cartState, dispatch: cartDispatch } = useCartStore(); + const router = useRouter(); + const id = (router.query.slug ?? "") as string; + + const [quantity, setQuantity] = useState(1); + const [isDisabled, setIsDisabled] = useState(false); + const [selectedSize, setSelectedSize] = useState(null); + const [selectedColor, setSelectedColor] = useState(null); + const [maxQuantity, setMaxQuantity] = useState(1); + + const { isOpen, onOpen, onClose } = useDisclosure(); + + const { data: product, isLoading } = useQuery( + [QueryKeys.PRODUCT, id], + () => api.getProduct(id), + { + onSuccess: (data: Product) => { + setIsDisabled(!(data?.is_available === true)); + setSelectedSize(getDefaultSize(data)); + setSelectedColor(getDefaultColor(data)); + }, + } + ); + + //* In/decrement quantity + const handleQtyChangeCounter = (isAdd = true) => { + const value = isAdd ? 1 : -1; + if (!isAdd && quantity === 1) return; + if (isAdd && quantity >= maxQuantity) return; + setQuantity(quantity + value); + }; + + //* Manual input quantity. + const handleQtyChangeInput = (e: React.FormEvent): void => { + const target = e.target as HTMLInputElement; + if (Number.isNaN(parseInt(target.value, 10))) { + setQuantity(1); + return; + } + const value = parseInt(target.value, 10); + if (value <= 0) { + setQuantity(1); + } else if (value > maxQuantity) { + setQuantity(maxQuantity); + } else { + setQuantity(value); + } + }; + + const updateMaxQuantity = (color: string, size: string) => { + if (product) { + const stockQty = getQtyInStock(product, color, size); + const cartQty = getQtyInCart( + cartState.cart.items, + product.id, + color, + size + ); + const max = stockQty > cartQty ? stockQty - cartQty : 0; + setMaxQuantity(max); + } + }; + + const handleAddToCart = () => { + if (!selectedColor || !selectedSize) { + return; + } + setIsDisabled(true); + const payload: CartAction = { + type: CartActionType.ADD_ITEM, + payload: { + id, + quantity, + color: selectedColor, + size: selectedSize, + }, + }; + cartDispatch(payload); + setMaxQuantity(maxQuantity - quantity); + setQuantity(1); + setIsDisabled(false); + }; + + const handleBuyNow = async () => { + handleAddToCart(); + await router.push(routes.CART); + }; + + const ProductNameSection = ( + + + {product?.name} + {!product?.is_available && ( + + unavailable + + )} + {product && isOutOfStock(product) && ( + + out of stock + + )} + + + {displayPrice(product?.price ?? 0)} + + + ); + + const renderSizeSection = ( + + + Sizes + {product?.size_chart && ( + + )} + + + {product?.sizes?.map((size, idx) => { + return ( + { + setQuantity(1); + if (size !== selectedSize) { + setSelectedSize(size); + if (selectedColor) { + updateMaxQuantity(selectedColor, size); + } + } else { + setSelectedSize(null); + } + }} + disabled={ + isDisabled || + (product + ? !isSizeAvailable(product, size) // size is not available for all colors + : false) || + (product && selectedColor + ? getQtyInStock(product, selectedColor, size) === 0 // size is not available for selected color + : false) + } + > + + {size} + + + ); + })} + + + ); + + const renderColorSection = ( + + + Colors + + + {product?.colors?.map((color, idx) => { + return ( + { + setQuantity(1); + if (color !== selectedColor) { + setSelectedColor(color); + if (selectedSize) { + updateMaxQuantity(color, selectedSize); + } + } else { + setSelectedColor(null); + } + }} + width="auto" + px={4} + disabled={ + isDisabled || + (product + ? !isColorAvailable(product, color) // color is not available for all sizes + : false) || + (product && selectedSize + ? getQtyInStock(product, color, selectedSize) === 0 // color is not available for selected size + : false) + } + > + + {color} + + + ); + })} + + + ); + + const renderQuantitySection = ( + + Quantity + + handleQtyChangeCounter(false)} + > + - + + + = maxQuantity + } + active={false.toString()} + onClick={() => handleQtyChangeCounter(true)} + > + + + +
+ + {product && selectedColor && selectedSize && product.is_available + ? displayStock(product, selectedColor, selectedSize) + : ""} + +
+
+ + + {product && selectedColor && selectedSize + ? displayQtyInCart( + cartState.cart.items, + product.id, + selectedColor, + selectedSize + ) + : ""} + + + {product && selectedColor && selectedSize && maxQuantity === 0 + ? "You have reached the maximum purchase quantity." + : ""} + + +
+ ); + + const purchaseButtons = ( + + + + + ); + + const renderMerchDetails = () => { + return ( + + + + + + {ProductNameSection} + + {renderSizeSection} + {renderColorSection} + {renderQuantitySection} + + {purchaseButtons} + + {/* {renderDescription} */} + + + + ); + }; + + const renderMerchPage = () => { + if (isLoading) return ; + if (product === undefined || product === null) return ; + return renderMerchDetails(); + }; + + return {renderMerchPage()}; +}; + +export default MerchDetail; + +export const getStaticProps: GetStaticProps<{ + slug: string; + product: Product | undefined; +}> = async ({ params }) => { + console.log("generating static props for /merch/product/[slug]"); + console.log("params", JSON.stringify(params)); + + // TODO: replace this with trpc/react-query call + if (!process.env.NEXT_PUBLIC_MERCH_API_ORIGIN) { + throw new Error("NEXT_PUBLIC_MERCH_API_ORIGIN is not defined"); + } + const res = await fetch( + `${ + process.env.NEXT_PUBLIC_MERCH_API_ORIGIN + }/trpc/getProduct?batch=1&input=${encodeURIComponent( + JSON.stringify({ "0": { id: params?.slug } }) + )}` + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const product = (await res.json())[0].result.data as Product; + + return { + props: { + slug: params?.slug as string, + product: product, + }, + }; +}; + +// eslint-disable-next-line @typescript-eslint/require-await +export const getStaticPaths: GetStaticPaths = async () => { + console.log("generating static paths for /merch/product/[slug]"); + + // TODO: replace this with trpc/react-query call + if (!process.env.NEXT_PUBLIC_MERCH_API_ORIGIN) { + throw new Error("NEXT_PUBLIC_MERCH_API_ORIGIN is not defined"); + } + const res = await fetch( + `${process.env.NEXT_PUBLIC_MERCH_API_ORIGIN}/trpc/getProducts` + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const products = (await res.json()).result.data.products as Product[]; + + return { + paths: products.map((product) => ({ + params: { + slug: product.id, + }, + })), + // https://nextjs.org/docs/pages/api-reference/functions/get-static-paths#fallback-blocking + fallback: "blocking", + }; +}; diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 00000000..45c873bf --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,5 @@ +{ + "git": { + "deploymentEnabled": false + } +} diff --git a/package.json b/package.json index 43e8fd79..6cae2d39 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "build": "turbo run build", + "build:ci": "turbo run build:ci", "test": "turbo run test", "test:watch": "turbo run test:watch", "cypress": "turbo run cypress", diff --git a/packages/merch-helpers/README.md b/packages/merch-helpers/README.md new file mode 100644 index 00000000..fc951a31 --- /dev/null +++ b/packages/merch-helpers/README.md @@ -0,0 +1,3 @@ +# merch-helpers + +todo... diff --git a/packages/merch-helpers/package.json b/packages/merch-helpers/package.json new file mode 100644 index 00000000..8f415bae --- /dev/null +++ b/packages/merch-helpers/package.json @@ -0,0 +1,16 @@ +{ + "name": "merch-helpers", + "version": "0.0.1", + "main": "src/index.ts", + "license": "Apache-2.0", + "scripts": { + "lint": "TIMING=1 eslint \"**/*.ts*\"", + "lint:fix": "TIMING=1 eslint --fix \"**/*.ts*\"" + }, + "devDependencies": { + "eslint": "^7.32.0", + "eslint-config-custom": "*", + "tsconfig": "*", + "typescript": "^4.5.2" + } +} diff --git a/packages/merch-helpers/src/index.ts b/packages/merch-helpers/src/index.ts new file mode 100644 index 00000000..756d6234 --- /dev/null +++ b/packages/merch-helpers/src/index.ts @@ -0,0 +1,2 @@ +export * from "./lib/orderstatus"; +export * from "./lib/price"; diff --git a/packages/merch-helpers/src/lib/orderstatus.ts b/packages/merch-helpers/src/lib/orderstatus.ts new file mode 100644 index 00000000..c0f43f9c --- /dev/null +++ b/packages/merch-helpers/src/lib/orderstatus.ts @@ -0,0 +1,26 @@ +import { OrderStatus } from 'types' +export const renderOrderStatus = (status: OrderStatus) => { + switch (status) { + case OrderStatus.ORDER_COMPLETED: + return "Order Collected"; + case OrderStatus.PAYMENT_COMPLETED: + return "Processing"; + case OrderStatus.PENDING_PAYMENT: + return "Order Received"; + default: + return "Item Delayed"; + } +}; + +export const getOrderStatusColor = (status: OrderStatus) => { + switch (status) { + case OrderStatus.ORDER_COMPLETED: + return "green.500"; + case OrderStatus.PAYMENT_COMPLETED: + return "primary.400"; + case OrderStatus.PENDING_PAYMENT: + return "primary.600"; + default: + return "red.500"; + } +}; diff --git a/packages/merch-helpers/src/lib/price.ts b/packages/merch-helpers/src/lib/price.ts new file mode 100644 index 00000000..02e61f58 --- /dev/null +++ b/packages/merch-helpers/src/lib/price.ts @@ -0,0 +1,104 @@ +import { Cart, PricedCart, Product, Promotion, PromoType } from "types"; + +const frontendURL = process.env.FRONTEND_STAGING_DOMAIN || ""; + +export class PricingError { + message: string; + + constructor(message: string) { + this.message = message; + } +} + +export const describeCart = (products: Product[], cart: PricedCart, orderID: string): string => { + const entries = [`${frontendURL}/orders/${orderID} | `]; + + const productMap: Record = {}; + for (const product of products) { + productMap[product.id] = product; + } + + for (const item of cart.items) { + const product = productMap[item.id]; + if (!product) { + throw new PricingError("unknown product ID: " + item.id); + } + let name = product.name; + if (item.size) { + name = `${product.name} (Size: ${item.size.toUpperCase()})`; + } + entries.push(`${name} x${item.quantity} - S$${item.discountedPrice}`); + } + + return entries.join("\n"); +} + +export const calculatePricing = ( + products: Product[], + cart: Cart, + promotion?: Promotion +): PricedCart => { + const productMap: Record = {}; + if (promotion && promotion.redemptionsRemaining <= 0) { + throw new PricingError("no redemptions left for the provided promotion"); + } + for (const product of products) { + productMap[product.id] = product; + } + const pricedItems = cart.items.map((item) => { + const product = productMap[item.id]; + if (!product) { + throw new PricingError("unknown product ID: " + item.id); + } + if (!product.colors.includes(item.color)) { + throw new PricingError(`invalid color ${item.color} for product ${item.id}`); + } + if (!product.sizes.includes(item.size)) { + throw new PricingError(`invalid size ${item.color} for product ${item.id}`); + } + let itemPrice = product.price * item.quantity; + if (!promotion) { + return { + ...item, + name: product.name, + image: product.images.length ? product.images[0] : undefined, + originalPrice: itemPrice, + discountedPrice: itemPrice, + }; + } + for (const discount of promotion.discounts) { + if (discount.appliesTo && !discount.appliesTo.includes(item.id)) { + continue; + } + if (discount.minimumQty && item.quantity < discount.minimumQty) { + continue; + } + switch (discount.promoType) { + case PromoType.FIXED_VALUE: + itemPrice -= discount.promoValue; + break; + case PromoType.PERCENTAGE: + itemPrice *= 1 - discount.promoValue; + itemPrice = Math.floor(itemPrice); + break; + } + } + itemPrice = Math.max(0, itemPrice); + return { + ...item, + name: product.name, + image: product.images.length ? product.images[0] : undefined, + originalPrice: product.price * item.quantity, + discountedPrice: itemPrice, + }; + }); + const subtotal = pricedItems.reduce((acc, item) => acc + item.originalPrice, 0); + const total = pricedItems.reduce((acc, item) => acc + item.discountedPrice, 0); + return { + promoCode: promotion?.promoCode, + subtotal: subtotal, + discount: subtotal-total, + total: total, + items: pricedItems, + }; +}; diff --git a/packages/merch-helpers/tsconfig.json b/packages/merch-helpers/tsconfig.json new file mode 100644 index 00000000..9a3c9440 --- /dev/null +++ b/packages/merch-helpers/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "tsconfig/base.json", + "include": [ + "**/*.ts", + ], + "exclude": [ + "out", + "dist", + "build", + "node_modules", + ".turbo" + ] +} diff --git a/packages/nodelogger/package.json b/packages/nodelogger/package.json index d5df223b..97af2cbd 100644 --- a/packages/nodelogger/package.json +++ b/packages/nodelogger/package.json @@ -17,8 +17,6 @@ "lint:fix": "TIMING=1 eslint --fix \"**/*.ts*\"" }, "dependencies": { - "@types/morgan": "^1.9.4", - "@types/winston": "^2.4.4", "morgan": "^1.10.0", "winston": "^3.8.2" }, @@ -26,6 +24,8 @@ "eslint": "^7.32.0", "eslint-config-custom": "*", "tsconfig": "*", - "typescript": "^4.5.2" + "typescript": "^4.5.2", + "@types/morgan": "^1.9.4", + "@types/winston": "^2.4.4" } } diff --git a/packages/nodelogger/src/index.ts b/packages/nodelogger/src/index.ts index e2a94e04..d34ecff7 100644 --- a/packages/nodelogger/src/index.ts +++ b/packages/nodelogger/src/index.ts @@ -1,4 +1,4 @@ import nodeloggerMiddleware from "./lib/morganMiddleware"; -import { Logger } from "./lib/winstonLogger" +import { Logger } from "./lib/winstonLogger"; export { Logger, nodeloggerMiddleware }; diff --git a/packages/nodelogger/src/lib/morganMiddleware.ts b/packages/nodelogger/src/lib/morganMiddleware.ts index f8990c2e..c8fe0845 100644 --- a/packages/nodelogger/src/lib/morganMiddleware.ts +++ b/packages/nodelogger/src/lib/morganMiddleware.ts @@ -1,15 +1,7 @@ import morgan, { StreamOptions } from "morgan"; -import { IncomingMessage } from "http"; - import { Logger } from "./winstonLogger"; -interface Request extends IncomingMessage { - body: { - query: string; - }; -} - const stream: StreamOptions = { write: (message) => Logger.http(message.substring(0, message.lastIndexOf("\n"))), @@ -20,14 +12,8 @@ const skip = () => { return env !== "development"; }; -const registerGraphQLToken = () => { - morgan.token("graphql-query", (req: Request) => `GraphQL ${req.body.query}`); -}; - -registerGraphQLToken(); - const morganMiddleware = morgan( - ":method :url :status :res[content-length] - :response-time ms\n:graphql-query", + ":method :url :status :res[content-length] - :response-time ms", { stream, skip } ); diff --git a/packages/nodelogger/tsconfig.json b/packages/nodelogger/tsconfig.json index b5340d5e..22ddefcc 100644 --- a/packages/nodelogger/tsconfig.json +++ b/packages/nodelogger/tsconfig.json @@ -29,6 +29,9 @@ "include": [ "src/**/*.ts" ], + "compilerOptions": { + "outDir": "dist", + }, "exclude": [ "dist", "build", diff --git a/packages/types/lib/cms.ts b/packages/types/lib/cms.ts index 697249a4..4e2f6a32 100644 --- a/packages/types/lib/cms.ts +++ b/packages/types/lib/cms.ts @@ -26,7 +26,7 @@ export interface Post { tags?: string[] | Tag[]; layout?: ( | { - columns: { + columns?: { width: 'oneThird' | 'half' | 'twoThirds' | 'full'; alignment: 'left' | 'center' | 'right'; richText?: { @@ -81,6 +81,8 @@ export interface User { email?: string; resetPasswordToken?: string; resetPasswordExpiration?: string; + salt?: string; + hash?: string; loginAttempts?: number; lockUntil?: string; password?: string; diff --git a/packages/types/lib/merch.ts b/packages/types/lib/merch.ts index 5644ab81..a10f00b9 100644 --- a/packages/types/lib/merch.ts +++ b/packages/types/lib/merch.ts @@ -1,26 +1,170 @@ +import { z } from "zod"; + +// Product export interface Product { id: string; name: string; - // todo + colors: string[]; + sizes: string[]; + images: string[]; + is_available: boolean; + price: number; + category: string; + size_chart?: string; + stock: { + [color: string]: { + [size: string]: number; + }; + }; +} + +// Order +export enum OrderStatus { + PENDING_PAYMENT = 1, + PAYMENT_COMPLETED = 2, + ORDER_COMPLETED = 3, } export interface Order { - // todo + id: string; + items: OrderItem[]; + transaction_id: string; + transaction_time: string | null; + payment_method: string; + customer_email: string; + status: OrderStatus; +} + +// Cart +export type CartState = { + cart: Cart; + voucher: string | undefined; + name: string; + billingEmail: string; +}; + +export const CartItem = z.object({ + id: z.string(), + color: z.string(), + size: z.string(), + quantity: z.number().gt(0), +}); + +export const Cart = z.object({ + items: z.array(CartItem), +}); + +export type Cart = z.infer; +export type CartItem = z.infer; + +// Promotion +export interface OrderItem { + id: string; + name: string; + image?: string; + color: string; + size: string; + price: number; + quantity: number; } export interface Promotion { promoCode: string; + maxRedemptions: number; + redemptionsRemaining: number; discounts: Array<{ promoType: PromoType; promoValue: number; // percent off or fixed value off based on promoType property - appliesTo: Array; // array of product ids - minimumQty: number; // minimum quantity of items in the order to apply the discount - maxRedemptions: number; - redemptionsRemaining: number; + appliesTo?: Array; // array of product ids + minimumQty?: number; // minimum quantity of items in the order to apply the discount }>; } -enum PromoType { +export type PricedCart = { + promoCode?: string; + subtotal: number; + discount: number; + total: number; + items: { + id: string; + name: string; + image?: string; + color: string; + size: string; + quantity: number; + originalPrice: number; + discountedPrice: number; + }[]; +}; + +export enum PromoType { PERCENTAGE = "PERCENTAGE", FIXED_VALUE = "FIXED_VALUE", } + +export type ReservedProduct = { + id: string; + quantity: number; +}; + +export type OrderHold = { + transaction_id: string; + expiry: string; + reserved_products: ReservedProduct[]; +}; + +export type ProductInfo = { + name: string; + image: string; + price: number; +}; + +export type ProductInfoMap = Record; + +export type CartPrice = { + currency: string; + subtotal: number; + discount: number; + grandTotal: number; +}; + +// API Types +export const QuotationRequest = Cart.merge( + z.object({ + promoCode: z.string().optional(), + }) +); + +export const CheckoutRequest = QuotationRequest.merge( + z.object({ + email: z.string(), + }) +); + +export type QuotationRequest = z.infer; +export type CheckoutRequest = z.infer; + +export type CheckoutResponse = Order & { + expiry: string; + price: { + grandTotal: number; + // todo: add rest of price object + }; + payment: { + method: "stripe"; + clientSecret: string; + }; +}; + +export type ProductsResponse = { + products: Product[]; +}; + +export type APIError = { + error: string; + detail?: string | object; +}; + +export type OrderHoldEntry = { + // todo: ??? +}; diff --git a/packages/ui/components/carousel/AnimatedCarousel.tsx b/packages/ui/components/carousel/AnimatedCarousel.tsx index 5e76a42c..2823a398 100644 --- a/packages/ui/components/carousel/AnimatedCarousel.tsx +++ b/packages/ui/components/carousel/AnimatedCarousel.tsx @@ -8,12 +8,14 @@ import { import { Image } from "../image"; import Link from "next/link"; +export type AnimatedCarouselItem = { + imageSrc: string; + href: string; + altText: string; +} + export interface AnimatedCarouselProps extends FlexProps { - items: Array<{ - imageSrc: string; - href: string; - altText: string; - }>; + items: Array; } const CarouselSlides = ({ items }: AnimatedCarouselProps) => { diff --git a/packages/ui/components/carousel/Carousel.tsx b/packages/ui/components/carousel/Carousel.tsx index c2d36464..dc293fab 100644 --- a/packages/ui/components/carousel/Carousel.tsx +++ b/packages/ui/components/carousel/Carousel.tsx @@ -1,6 +1,6 @@ -import React from "react"; -import { AnimatedCarousel, AnimatedCarouselProps } from "./AnimatedCarousel"; +import { AnimatedCarousel, AnimatedCarouselProps, AnimatedCarouselItem } from "./AnimatedCarousel"; +export type CarouselItem = AnimatedCarouselItem; export interface CarouselProps extends AnimatedCarouselProps {} export const Carousel = ({ items }: CarouselProps) => { diff --git a/packages/ui/components/carousel/index.tsx b/packages/ui/components/carousel/index.tsx index 73328e28..ff97b88d 100644 --- a/packages/ui/components/carousel/index.tsx +++ b/packages/ui/components/carousel/index.tsx @@ -1,2 +1,3 @@ export { Carousel } from "./Carousel"; -export type { CarouselProps } from "./Carousel"; \ No newline at end of file +export type { CarouselProps } from "./Carousel"; +export type { CarouselItem } from "./Carousel"; \ No newline at end of file diff --git a/packages/ui/components/layout/Layout.tsx b/packages/ui/components/layout/Layout.tsx index 64f52451..fb56629b 100644 --- a/packages/ui/components/layout/Layout.tsx +++ b/packages/ui/components/layout/Layout.tsx @@ -1,8 +1,6 @@ -import { NavBar } from "../navbar"; -import { Footer } from "../footer"; import React from "react"; -import { NavBarProps, FooterProps } from "ui"; import { Box } from "@chakra-ui/react"; +import { NavBar, Footer, NavBarProps, FooterProps } from "ui"; interface LayoutProps { navbarProps: NavBarProps; @@ -13,7 +11,7 @@ interface LayoutProps { export const Layout = ({ navbarProps, footerProps, children }: LayoutProps) => { return ( <> -
+
NTU School of Computer Science & Engineering
diff --git a/packages/ui/components/merch/Card.tsx b/packages/ui/components/merch/Card.tsx new file mode 100644 index 00000000..610e7032 --- /dev/null +++ b/packages/ui/components/merch/Card.tsx @@ -0,0 +1,67 @@ +import { ArrowForwardIcon } from "@chakra-ui/icons"; +import { Box, Image, Text, GridItem, Flex, Badge, Center } from "@chakra-ui/react"; +import Link from "next/link"; +import { displayPrice } from "web/features/merch/functions"; +import { routes } from "web/features/merch/constants" + +type CardProps = { + _productId: string; + imgSrc?: string; + text: string; + price: number; + sizeRange: string; + isOutOfStock?: boolean; +}; + +export const Card = ({ _productId, imgSrc, text, price, sizeRange, isOutOfStock }: CardProps) => { + return ( + + + +
+ +
+ + + {text} + {displayPrice(price)} + + {!isOutOfStock && ( + + + {sizeRange} + + + + )} + {isOutOfStock && ( + + + out of stock + + + )} + +
+ +
+ ); +}; diff --git a/packages/ui/components/merch/CartButton.tsx b/packages/ui/components/merch/CartButton.tsx new file mode 100644 index 00000000..6f001d9b --- /dev/null +++ b/packages/ui/components/merch/CartButton.tsx @@ -0,0 +1,12 @@ +import { Icon } from "@chakra-ui/react"; + +const CartButton = () => { + return( + + + + ) +} + +export default CartButton; diff --git a/packages/ui/components/merch/CartHeader.tsx b/packages/ui/components/merch/CartHeader.tsx new file mode 100644 index 00000000..d0359986 --- /dev/null +++ b/packages/ui/components/merch/CartHeader.tsx @@ -0,0 +1,45 @@ +import { + Box, + Flex, + HStack, + Spacer, + Show, + Hide, + Icon, +} from "@chakra-ui/react"; +import Link from 'next/link'; +import routes from "../../../../apps/web/features/merch/constants/routes"; + + +export const CartHeader = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/packages/ui/components/merch/EmptyProductView.tsx b/packages/ui/components/merch/EmptyProductView.tsx new file mode 100644 index 00000000..401dc415 --- /dev/null +++ b/packages/ui/components/merch/EmptyProductView.tsx @@ -0,0 +1,24 @@ +import React, { useEffect } from "react"; +import { Center, Flex, Heading, Spinner, Text } from "@chakra-ui/react"; +import { useRouter } from "next/router"; +import { routes } from "web/features/merch/constants"; + +export const EmptyProductView: React.FC = () => { + const router = useRouter(); + + useEffect(() => { + setTimeout(() => { + router.push(routes.HOME).catch((err) => console.error(err)); + }, 3000); + }, []); + + return ( +
+ + The item does not exist... + + Redirecting you in 3 seconds... + +
+ ); +}; diff --git a/packages/ui/components/merch/LoadingScreen.tsx b/packages/ui/components/merch/LoadingScreen.tsx new file mode 100644 index 00000000..ca42fb86 --- /dev/null +++ b/packages/ui/components/merch/LoadingScreen.tsx @@ -0,0 +1,21 @@ +import { Flex, Spinner, Text } from "@chakra-ui/react"; + +export type LoadingScreenProps = { + minH?: string; + text: string; +}; + +export const LoadingScreen = ({ minH = "50vh", text = "" }: LoadingScreenProps) => { + return ( + + + {text} + + ); +}; \ No newline at end of file diff --git a/packages/ui/components/merch/MerchCarousel.tsx b/packages/ui/components/merch/MerchCarousel.tsx new file mode 100644 index 00000000..30f4b20d --- /dev/null +++ b/packages/ui/components/merch/MerchCarousel.tsx @@ -0,0 +1,116 @@ +import "swiper/css"; +import "swiper/less/autoplay"; +import { Flex, Box, Image } from "@chakra-ui/react"; +import { Controller } from "swiper"; +import { Swiper, SwiperSlide, useSwiper } from "swiper/react"; +import type { Swiper as SwiperType } from "swiper"; +import React, { ReactElement, useState } from "react"; +import { ChevronLeftIcon, ChevronRightIcon } from "@chakra-ui/icons"; + +export type Slide = { + id: string; + component: ReactElement; +}; + +export type MarkerProps = { + index: number; + slideIndex: number; + slideTo: (index: number) => void; +}; + +type CarouselProps = { + images: string[]; +}; + +const Marker: React.FC = (props: MarkerProps) => { + // Swiper Ref + const swiper = useSwiper(); + const { index, slideIndex, slideTo } = props; + + // Click Handler + const handleClick = () => { + slideTo(index); + swiper.slideTo(index); + }; + + return ( + + ); +}; + +const Controllerer = ({ length }: { length: number }) => { + const swiper = useSwiper(); + const [curIndex, setCurIndex] = useState(0); + const slideLeft = () => { + swiper.slidePrev(); + if (curIndex - 1 < 0) { + setCurIndex(length - 1); + } else { + setCurIndex((curIndex - 1) % length); + } + }; + const slideRight = () => { + swiper.slideNext(); + setCurIndex((curIndex + 1) % length); + }; + const slideTo = (index: number): void => { + setCurIndex(index); + }; + + if (length < 2) return null + + return ( + + + + {Array(length) + .fill(null) + .map((url, index) => ( + + ))} + + + + ); +}; + +export const MerchCarousel = ({ images }: CarouselProps) => { + const [controlledSwiper, setControlledSwiper] = useState(null); + + return ( + + + + {images.map((url, idx) => ( + + + + ))} + + + + + {images.map((url, idx) => ( + + ))} + + + + + ); +}; + diff --git a/packages/ui/components/merch/OrderItem.tsx b/packages/ui/components/merch/OrderItem.tsx new file mode 100644 index 00000000..75bc6582 --- /dev/null +++ b/packages/ui/components/merch/OrderItem.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { Flex, Grid, GridItem, Image, Text, Divider, Box } from "@chakra-ui/react"; +import { displayPrice } from "web/features/merch/functions/currency"; +import { Order } from "types"; + +export type OrderItemProps = { + isMobile: boolean; + orderData: Order; +}; + +const OrderItem: React.FC = (props: OrderItemProps) => { + const { isMobile, orderData } = props; + + const flexItemConfig = { + alignItems: "center", + h: isMobile ? "auto" : 100, + justifyContent: isMobile ? "start" : "center", + }; + return ( + <> + {orderData.items.map((data, index) => ( +
+ + + + + + + + {data?.name} + + Size: + + {data.size} + + + + Color: + + {data.color} + + + + + + + + {isMobile && "Unit Price:"} {displayPrice(Number(data?.price) ?? 0)} + + + + + + + {isMobile && "Quantity:"} x{data?.quantity ?? 0} + + + + + + + {isMobile && "Subtotal:"} {displayPrice(Number(data.price) * data.quantity)} + + + + + + +
+ ))} + + ); +} + +export default OrderItem; diff --git a/packages/ui/components/merch/OrderNumber.tsx b/packages/ui/components/merch/OrderNumber.tsx new file mode 100644 index 00000000..8c20838d --- /dev/null +++ b/packages/ui/components/merch/OrderNumber.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Badge, Flex, Heading, Text } from "@chakra-ui/react"; +import { Order, OrderStatus } from "types"; +import { getOrderStatusColor, renderOrderStatus } from "merch-helpers"; + +interface OrderNumberProps { + orderState: Order | null; +} + +const OrderNumber: React.FC = ({ orderState }) => ( +
+ + + + {renderOrderStatus(orderState?.status ?? OrderStatus.PENDING_PAYMENT)} + + Order Number + + {orderState?.id.split("-")[0]} + + + {orderState?.id} + + + Order date:{" "} + {orderState?.transaction_time + ? new Date(`${orderState.transaction_time}`).toLocaleString( + "en-sg" + ) + : ""} + + {/*Last update: {orderState?.lastUpdate}*/} + + +
+
+ + + + Order Number + + {renderOrderStatus(orderState?.status ?? OrderStatus.PENDING_PAYMENT)} + + + + {orderState?.id.split("-")[0]} + + + {orderState?.id} + + + + + Order date:{" "} + {orderState?.transaction_time + ? new Date(`${orderState.transaction_time}`).toLocaleString( + "en-sg" + ) + : ""} + + {/*Last update: {orderState?.lastUpdate}*/} + + +
+); + +export default OrderNumber; diff --git a/packages/ui/components/merch/Page.tsx b/packages/ui/components/merch/Page.tsx new file mode 100644 index 00000000..5926381f --- /dev/null +++ b/packages/ui/components/merch/Page.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from "react"; +import { Flex, FlexProps, Box } from "@chakra-ui/react"; + +type PageProps = FlexProps & { + children: ReactNode; + hideHeader?: boolean; + contentWidth?: string; + contentPadding?: number[]; +}; + +export const Page = ({ + children, + contentWidth = "1400px", + contentPadding = [4, 6, 8], + ...props +}: PageProps) => { + return ( + + + + {children} + + + ); +}; diff --git a/packages/ui/components/merch/QRCode.tsx b/packages/ui/components/merch/QRCode.tsx new file mode 100644 index 00000000..3e823999 --- /dev/null +++ b/packages/ui/components/merch/QRCode.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Image, Flex, Text } from "@chakra-ui/react"; + +interface QRCodeProps { + order: string | undefined; +} + +const QRCode: React.FC = ({ order }) => ( + + QRCode + + Please screenshot this QR code and show it at SCSE Lounge to collect your order. + Alternatively, show the email receipt you have received. + + + For any assistance, please contact our email address: + merch@ntuscse.com + + +); + +export default QRCode; diff --git a/packages/ui/components/merch/SizeChartDialog.tsx b/packages/ui/components/merch/SizeChartDialog.tsx new file mode 100644 index 00000000..0f999485 --- /dev/null +++ b/packages/ui/components/merch/SizeChartDialog.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { + Modal, + ModalOverlay, + ModalContent, + Image, + ModalHeader, + ModalBody, + ModalCloseButton, + Divider, +} from "@chakra-ui/react"; + +type SizeChartDialogType = { + sizeChart?: string; + isOpen: boolean; + onClose: () => void; +}; + +export const SizeChartDialog: React.FC = ({ sizeChart, isOpen, onClose }) => { + return ( + + + + Size Chart + + + + + + + + ); +}; diff --git a/packages/ui/components/merch/SizeOption.tsx b/packages/ui/components/merch/SizeOption.tsx new file mode 100644 index 00000000..c8e5728a --- /dev/null +++ b/packages/ui/components/merch/SizeOption.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Box, BoxProps } from "@chakra-ui/react"; + +type SizeOptionType = BoxProps & { + active: string; + disabled?: boolean; + onClick: (param: any) => void; +}; + +export const SizeOption: React.FC = (props) => { + const { active = false.toString(), disabled = false, children } = props; + return ( + + {children} + + ); +}; diff --git a/packages/ui/components/merch/cart/CartCard.tsx b/packages/ui/components/merch/cart/CartCard.tsx new file mode 100644 index 00000000..cfa8492c --- /dev/null +++ b/packages/ui/components/merch/cart/CartCard.tsx @@ -0,0 +1,17 @@ +import { ReactNode } from "react"; +import { Flex, FlexProps, Text, Divider } from "@chakra-ui/react"; + +type CartCardProps = FlexProps & { + title?: string; + children: ReactNode; +}; + +export const CartCard = ({ children, title, ...props }: CartCardProps) => { + return ( + + {title} + + {children} + + ); +}; diff --git a/packages/ui/components/merch/cart/CartEmptyView.tsx b/packages/ui/components/merch/cart/CartEmptyView.tsx new file mode 100644 index 00000000..6bed5b38 --- /dev/null +++ b/packages/ui/components/merch/cart/CartEmptyView.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import Link from 'next/link'; +import { Center, Flex, Heading, Button } from "@chakra-ui/react"; +import { routes } from "web/features/merch/constants"; + +export const CartEmptyView: React.FC = () => ( +
+ + There are no items currently in your cart. + + + + +
+); \ No newline at end of file diff --git a/packages/ui/components/merch/cart/CartHeader.tsx b/packages/ui/components/merch/cart/CartHeader.tsx new file mode 100644 index 00000000..34cd1e76 --- /dev/null +++ b/packages/ui/components/merch/cart/CartHeader.tsx @@ -0,0 +1,19 @@ +import { Box, Grid, Heading } from "@chakra-ui/react"; + +export const CartHeader = () => { + return ( + + + + Product + + {["Unit Price", "Quantity", "Subtotal"].map((item, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {item} + + ))} + + + ); +}; diff --git a/packages/ui/components/merch/cart/CartItemCard.tsx b/packages/ui/components/merch/cart/CartItemCard.tsx new file mode 100644 index 00000000..dff850f6 --- /dev/null +++ b/packages/ui/components/merch/cart/CartItemCard.tsx @@ -0,0 +1,176 @@ +import React from "react"; +import Link from "next/link"; +import { + Button, + Flex, + Grid, + GridItem, + Image, + Text, + Input, + InputLeftAddon, + InputRightAddon, + InputGroup, + Box, + Center, +} from "@chakra-ui/react"; +import { DeleteIcon, SmallCloseIcon } from "@chakra-ui/icons"; +import { CartItem, Product } from "types/lib/merch"; +import { displayPrice, getQtyInStock } from "web/features/merch/functions"; +import { routes } from "web/features/merch/constants"; + +export type CartItemProps = { + isMobile: boolean; + data: CartItem; + productInfo?: Product; + isLoading: boolean; + onRemove: (productId: string, size: string, color: string) => void; + onQuantityChange: (productId: string, size: string, color: string, qty: number) => void; +}; + +const MIN_ITEM_CNT = 1; + +export const CartItemCard: React.FC = ({ isMobile, data, onRemove, onQuantityChange, productInfo }) => { + const MAX_ITEM_CNT = productInfo ? getQtyInStock(productInfo, data.color, data.size) : 1; + const handleQtyChangeCounter = (isAdd: boolean) => { + const value = isAdd ? 1 : -1; + if (!isAdd && data.quantity === MIN_ITEM_CNT) { + onRemove(data.id, data.size, data.color); + return; + } + if (isAdd && data.quantity === MAX_ITEM_CNT) return; + onQuantityChange(data.id, data.size, data.color, data.quantity + value); + }; + + const handleQtyChangeInput = (e: React.FormEvent): void => { + const target = e.target as HTMLInputElement; + if (Number.isNaN(parseInt(target.value, 10))) { + onQuantityChange(data.id, data.size, data.color, MIN_ITEM_CNT); + } else { + const value = parseInt(target.value, 10); + if (value <= 0) { + onRemove(data.id, data.size, data.color); + } else if (value > MAX_ITEM_CNT) { + onQuantityChange(data.id, data.size, data.color, MAX_ITEM_CNT); + } else { + onQuantityChange(data.id, data.size, data.color, value); + } + } + }; + const unitPrice = displayPrice(productInfo?.price ?? 0); + const subTotalPrice = displayPrice((productInfo?.price ?? 0) * data.quantity); + + const quantityInput = ( + + + handleQtyChangeCounter(false)}> + - + + + handleQtyChangeCounter(true)}> + + + + +
+ + In stock: {MAX_ITEM_CNT} + +
+
+ ); + const desktopView = ( + + + + + + + + + + + {productInfo?.name} + + + + Size: + + {data.size} + + + + Color: + + {data.color} + + + + + + + {unitPrice} + + + + {quantityInput} + + + + {subTotalPrice} + + + + + + + ); + + const mobileView = ( + + + + + + + + {productInfo?.name} + + + + + + Size:{" "} + + {data.size} + + + + Color:{" "} + {data.color} + + + Unit Price: {unitPrice} + {quantityInput} + Subtotal: {subTotalPrice} + + + ); + return {!isMobile ? desktopView : mobileView}; +}; diff --git a/packages/ui/components/merch/cart/CartRemoveModal.tsx b/packages/ui/components/merch/cart/CartRemoveModal.tsx new file mode 100644 index 00000000..7f99b2f1 --- /dev/null +++ b/packages/ui/components/merch/cart/CartRemoveModal.tsx @@ -0,0 +1,40 @@ +import React from "react"; + +import { Button, Divider, Modal, ModalOverlay, ModalContent, ModalFooter, ModalBody, Text } from "@chakra-ui/react"; + +type CartRemoveModalType = { + isOpen: boolean; + onClose: () => void; + removeItem: () => void; +}; + +export const CartRemoveModal: React.FC = (props) => { + const { isOpen, onClose, removeItem } = props; + return ( + + + + + Do you want to remove this product? + + + + + + + + + ); +}; diff --git a/packages/ui/components/merch/cart/index.tsx b/packages/ui/components/merch/cart/index.tsx new file mode 100644 index 00000000..297129fa --- /dev/null +++ b/packages/ui/components/merch/cart/index.tsx @@ -0,0 +1,5 @@ +export * from "./CartCard" +export * from "./CartHeader" +export * from "./CartItemCard" +export * from "./CartEmptyView" +export * from "./CartRemoveModal" \ No newline at end of file diff --git a/packages/ui/components/merch/index.tsx b/packages/ui/components/merch/index.tsx new file mode 100644 index 00000000..07f5f004 --- /dev/null +++ b/packages/ui/components/merch/index.tsx @@ -0,0 +1,9 @@ +export * from "./cart" +export * from "./Card" +export * from "./EmptyProductView" +export * from "./LoadingScreen" +export * from "./MerchCarousel" +export * from "./Page" +export * from "./SizeChartDialog" +export * from "./SizeOption" +export * from "./skeleton" diff --git a/packages/ui/components/merch/skeleton/LoadingScreen.tsx b/packages/ui/components/merch/skeleton/LoadingScreen.tsx new file mode 100644 index 00000000..2622b988 --- /dev/null +++ b/packages/ui/components/merch/skeleton/LoadingScreen.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Flex, Spinner, Text } from "@chakra-ui/react"; + +export type LoadingScreenType = { + minH?: string; + text: string; +}; +const LoadingScreen: React.FC = (props) => { + const { minH = "50vh", text = "" } = props; + return ( + + + {text} + + ); +}; + +export default LoadingScreen; diff --git a/packages/ui/components/merch/skeleton/MerchDetailSkeleton.tsx b/packages/ui/components/merch/skeleton/MerchDetailSkeleton.tsx new file mode 100644 index 00000000..3617a961 --- /dev/null +++ b/packages/ui/components/merch/skeleton/MerchDetailSkeleton.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Flex, Skeleton, Divider, GridItem, Grid } from "@chakra-ui/react"; + +export const MerchDetailSkeleton: React.FC = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/packages/ui/components/merch/skeleton/MerchListSkeleton.tsx b/packages/ui/components/merch/skeleton/MerchListSkeleton.tsx new file mode 100644 index 00000000..6fa15b8f --- /dev/null +++ b/packages/ui/components/merch/skeleton/MerchListSkeleton.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { Grid, Skeleton, SkeletonText, GridItem } from "@chakra-ui/react"; + +export const MerchListSkeleton: React.FC = () => { + return ( + + {new Array(8).fill(null).map((_, i: number) => ( + + + + + ))} + + ); +}; diff --git a/packages/ui/components/merch/skeleton/index.tsx b/packages/ui/components/merch/skeleton/index.tsx new file mode 100644 index 00000000..86c3dd87 --- /dev/null +++ b/packages/ui/components/merch/skeleton/index.tsx @@ -0,0 +1,2 @@ +export { MerchListSkeleton } from "./MerchListSkeleton"; +export { MerchDetailSkeleton } from "./MerchDetailSkeleton"; diff --git a/packages/ui/components/navbar/Logo.tsx b/packages/ui/components/navbar/Logo.tsx index 76a169b4..9892f57e 100644 --- a/packages/ui/components/navbar/Logo.tsx +++ b/packages/ui/components/navbar/Logo.tsx @@ -1,5 +1,5 @@ -import React from "react"; import { Box, Stack, Link } from "@chakra-ui/react"; +import NextLink from "next/link"; import { Image } from "../image"; export interface LogoProps { @@ -9,7 +9,11 @@ export interface LogoProps { export const Logo = ({ src, alt }: LogoProps) => { return ( - + { + + const router = useRouter() + const [route, setRoute] = useState('') + + useEffect(() => { + setRoute(router.pathname), + [] + }) + + const regexp = /\/merch*/; + return ( { {/* CTA Button -> Contact */} + {/* If on merch site, change to Cart */} { w="max-content" display={{ base: "block", xl: "block" }} > - Contact + {route.match(regexp) ? : + Contact}
); diff --git a/packages/ui/components/navbar/MenuLink.tsx b/packages/ui/components/navbar/MenuLink.tsx index 0fcc4b19..d7f565a9 100644 --- a/packages/ui/components/navbar/MenuLink.tsx +++ b/packages/ui/components/navbar/MenuLink.tsx @@ -1,5 +1,5 @@ import { Link, Text } from "@chakra-ui/react"; -import React from "react"; +import NextLink from "next/link"; import { useRouter } from "next/router"; export interface MenuLinkProps { @@ -11,6 +11,7 @@ export const MenuLink = ({ label, href = "/" }: MenuLinkProps) => { const router = useRouter(); return ( =8.1.0": + version "20.1.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.1.4.tgz#83f148d2d1f5fe6add4c53358ba00d97fc4cdb71" + integrity sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q== + "@types/node@^14.14.31": version "14.18.35" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.35.tgz#879c4659cb7b3fe515844f029c75079c941bb65c" @@ -6170,6 +6861,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/uuid@^9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.1.tgz#98586dc36aee8dacc98cc396dbca8d0429647aa6" + integrity sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA== + "@types/warning@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52" @@ -6537,6 +7233,11 @@ acorn@^8.8.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== +add@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/add/-/add-2.0.6.tgz#248f0a9f6e5a528ef2295dbeec30532130ae2235" + integrity sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q== + address@^1.0.1: version "1.2.2" resolved "https://registry.yarnpkg.com/address/-/address-1.2.2.tgz#2b5248dac5485a6390532c6a517fda2e3faac89e" @@ -8006,6 +8707,13 @@ cookie@0.5.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +copy-anything@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.3.tgz#206767156f08da0e02efd392f71abcdf79643559" + integrity sha512-fpW2W/BqEzqPp29QS+MwwfisHCQZtiduTe/m8idFo0xbti9fIZ2WVhAsCv4ggFVH3AgCkVdpoOCtQC6gBrdhjw== + dependencies: + is-what "^4.1.8" + copy-to-clipboard@3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz#115aa1a9998ffab6196f93076ad6da3b913662ae" @@ -8060,6 +8768,14 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +cors@^2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" @@ -8863,6 +9579,11 @@ dotenv@^16.0.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.1.1.tgz#9105a6c486ab66076231fbe7cf4ba77131a61d65" integrity sha512-UGmzIqXU/4b6Vb3R1Vrfd/4vGgVlB+mO+vEixOdfRhLeppkyW2BMhuK7TL8d0el+q9c4lW9qK2wZYhNLFhXYLA== +dotenv@^16.3.1: + version "16.3.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" + integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== + dotenv@^8.2.0, dotenv@^8.6.0: version "8.6.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" @@ -9781,6 +10502,13 @@ fast-xml-parser@4.0.11: dependencies: strnum "^1.0.5" +fast-xml-parser@4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.1.2.tgz#5a98c18238d28a57bbdfa9fe4cda01211fff8f4a" + integrity sha512-CDYeykkle1LiA/uqQyNwYpFbyF6Axec6YapmpUP+/RHWIoR1zKjocdvNaTsxCxZzQ6v9MLXaSYm9Qq0thv0DHg== + dependencies: + strnum "^1.0.5" + fastest-levenshtein@^1.0.12: version "1.0.16" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" @@ -10197,6 +10925,11 @@ functions-have-names@^1.2.2: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +fuzzysort@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/fuzzysort/-/fuzzysort-2.0.4.tgz#a21d1ce8947eaf2797dc3b7c28c36db9d1165f84" + integrity sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw== + gauge@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" @@ -10875,6 +11608,11 @@ inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, i resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== + ini@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" @@ -11227,6 +11965,11 @@ is-weakset@^2.0.1: call-bind "^1.0.2" get-intrinsic "^1.1.1" +is-what@^4.1.8: + version "4.1.8" + resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.8.tgz#0e2a8807fda30980ddb2571c79db3d209b14cbe4" + integrity sha512-yq8gMao5upkPoGEU9LsB2P+K3Kt8Q3fQFCGyNCWOAnJAMzEXVV9drYb0TXr42TTliLLhKIBvulgAXgtLLnwzGA== + is-wsl@^2.1.1, is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" @@ -12573,6 +13316,13 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mnemonist@0.38.3: + version "0.38.3" + resolved "https://registry.yarnpkg.com/mnemonist/-/mnemonist-0.38.3.tgz#35ec79c1c1f4357cfda2fe264659c2775ccd7d9d" + integrity sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw== + dependencies: + obliterator "^1.6.1" + monaco-editor@^0.38.0: version "0.38.0" resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.38.0.tgz#7b3cd16f89b1b8867fcd3c96e67fccee791ff05c" @@ -12732,12 +13482,12 @@ next-tick@1, next-tick@^1.1.0: resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== -next@13.4.6: - version "13.4.6" - resolved "https://registry.yarnpkg.com/next/-/next-13.4.6.tgz#ebe52f5c74d60176d45b45e73f25a51103713ea4" - integrity sha512-sjVqjxU+U2aXZnYt4Ud6CTLNNwWjdSfMgemGpIQJcN3Z7Jni9xRWbR0ie5fQzCg87aLqQVhKA2ud2gPoqJ9lGw== +next@13.4.9: + version "13.4.9" + resolved "https://registry.yarnpkg.com/next/-/next-13.4.9.tgz#473de5997cb4c5d7a4fb195f566952a1cbffbeba" + integrity sha512-vtefFm/BWIi/eWOqf1GsmKG3cjKw1k3LjuefKRcL3iiLl3zWzFdPG3as6xtxrGO6gwTzzaO1ktL4oiHt/uvTjA== dependencies: - "@next/env" "13.4.6" + "@next/env" "13.4.9" "@swc/helpers" "0.5.1" busboy "1.6.0" caniuse-lite "^1.0.30001406" @@ -12746,15 +13496,15 @@ next@13.4.6: watchpack "2.4.0" zod "3.21.4" optionalDependencies: - "@next/swc-darwin-arm64" "13.4.6" - "@next/swc-darwin-x64" "13.4.6" - "@next/swc-linux-arm64-gnu" "13.4.6" - "@next/swc-linux-arm64-musl" "13.4.6" - "@next/swc-linux-x64-gnu" "13.4.6" - "@next/swc-linux-x64-musl" "13.4.6" - "@next/swc-win32-arm64-msvc" "13.4.6" - "@next/swc-win32-ia32-msvc" "13.4.6" - "@next/swc-win32-x64-msvc" "13.4.6" + "@next/swc-darwin-arm64" "13.4.9" + "@next/swc-darwin-x64" "13.4.9" + "@next/swc-linux-arm64-gnu" "13.4.9" + "@next/swc-linux-arm64-musl" "13.4.9" + "@next/swc-linux-x64-gnu" "13.4.9" + "@next/swc-linux-x64-musl" "13.4.9" + "@next/swc-win32-arm64-msvc" "13.4.9" + "@next/swc-win32-ia32-msvc" "13.4.9" + "@next/swc-win32-x64-msvc" "13.4.9" no-case@^3.0.4: version "3.0.4" @@ -12931,7 +13681,7 @@ nwsapi@^2.2.2: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.2.tgz#e5418863e7905df67d51ec95938d67bf801f0bb0" integrity sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw== -object-assign@^4.0.1, object-assign@^4.1.1: +object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -13009,6 +13759,11 @@ objectorarray@^1.0.5: resolved "https://registry.yarnpkg.com/objectorarray/-/objectorarray-1.0.5.tgz#2c05248bbefabd8f43ad13b41085951aac5e68a5" integrity sha512-eJJDYkhJFFbBBAxeh8xW+weHlkI28n2ZdQV/J/DNfWfSKlGEf2xcfAbZTv3riEXHAhL9SVOTs2pRmXiSTf78xg== +obliterator@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/obliterator/-/obliterator-1.6.1.tgz#dea03e8ab821f6c4d96a299e17aef6a3af994ef3" + integrity sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig== + on-exit-leak-free@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz#5c703c968f7e7f851885f6459bf8a8a57edc9cc4" @@ -13329,6 +14084,14 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +path@^0.12.7: + version "0.12.7" + resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f" + integrity sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q== + dependencies: + process "^0.11.1" + util "^0.10.3" + pathe@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.0.tgz#e2e13f6c62b31a3289af4ba19886c230f295ec03" @@ -14279,7 +15042,7 @@ process-warning@^1.0.0: resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-1.0.0.tgz#980a0b25dc38cd6034181be4b7726d89066b4616" integrity sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q== -process@^0.11.10: +process@^0.11.1, process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== @@ -14782,6 +15545,11 @@ react-side-effect@^2.1.0: resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.2.tgz#dc6345b9e8f9906dc2eeb68700b615e0b4fe752a" integrity sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw== +react-ssr-prepass@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz#bc4ca7fcb52365e6aea11cc254a3d1bdcbd030c5" + integrity sha512-yFNHrlVEReVYKsLI5lF05tZoHveA5pGzjFbFJY/3pOqqjGOmMmqx83N4hIjN2n6E1AOa+eQEUxs3CgRnPmT0RQ== + react-style-singleton@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" @@ -15046,6 +15814,11 @@ remark-slug@^6.0.0: mdast-util-to-string "^1.0.0" unist-util-visit "^2.0.0" +remove-accents@0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5" + integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA== + renderkid@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a" @@ -15774,6 +16547,11 @@ sshpk@^1.14.1: safer-buffer "^2.0.2" tweetnacl "~0.14.0" +ssr-window@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/ssr-window/-/ssr-window-4.0.2.tgz#dc6b3ee37be86ac0e3ddc60030f7b3bc9b8553be" + integrity sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ== + stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" @@ -15976,6 +16754,14 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== +stripe@^12.5.0: + version "12.5.0" + resolved "https://registry.yarnpkg.com/stripe/-/stripe-12.5.0.tgz#965168a87103a985fac950a7184a71bb107ac904" + integrity sha512-eDBh4bv+Uo+GdhjnQ246lM5KaOReoBzxFltgW0HJqco/QEAgSYxZPOpFbd5+gJnZlRqHSB5B+Zqw273SlbdPag== + dependencies: + "@types/node" ">=8.1.0" + qs "^6.11.0" + strnum@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" @@ -16048,6 +16834,13 @@ sucrase@^3.20.3: pirates "^4.0.1" ts-interface-checker "^0.1.9" +superjson@^1.10.0: + version "1.12.2" + resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.12.2.tgz#072471f1e6add2d95a38b77fef8c7a199d82103a" + integrity sha512-ugvUo9/WmvWOjstornQhsN/sR9mnGtWGYeTxFuqLb4AiT4QdUavjGFRALCPKWWnAiUJ4HTpytj5e0t5HoMRkXg== + dependencies: + copy-anything "^3.0.2" + supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -16096,6 +16889,13 @@ swc-minify-webpack-plugin@^2.1.0: resolved "https://registry.yarnpkg.com/swc-minify-webpack-plugin/-/swc-minify-webpack-plugin-2.1.1.tgz#2c63fe592d49541733d7557b3af8f97c7ffa78b9" integrity sha512-/9ud/libNWUC5p71vXWhW/O2Nc0essW8D9pY4P4ol0ceM8OcFbNr41R9YFqTkmktqUL2t0WwXau+FkR4T1+PJA== +swiper@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/swiper/-/swiper-9.4.0.tgz#ceb7dd1d05b93702aceba42d94ee37c381beab6a" + integrity sha512-AKame5qkFnNKCJ8Bfn3YuOi/LoUuAESVdCv/BJH5fKgdAUFRtImZXV3gdYlYovXbasJDV6hHNWdQvdzPB6aebw== + dependencies: + ssr-window "^4.0.2" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -16414,6 +17214,16 @@ triple-beam@^1.3.0: resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw== +trpc-panel@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/trpc-panel/-/trpc-panel-1.3.4.tgz#70276d0d24b6561b9e34158e83a5f8e59146bc89" + integrity sha512-u5/dCi/AAp2tpJcCL5ZCfrdJtHHu8hrtm2hzSBZCE7z9Tw6MB1rCcliSQvgMPIEXMQrgwXk4t4IedfWkxioKng== + dependencies: + fuzzysort "^2.0.4" + path "^0.12.7" + url "^0.11.0" + zod-to-json-schema "^3.20.0" + truncate-utf8-bytes@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz#405923909592d56f78a5818434b0b78489ca5f2b" @@ -16871,6 +17681,11 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" +use-sync-external-store@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + utf8-byte-length@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz#f45f150c4c66eee968186505ab93fcbb8ad6bf61" @@ -16881,6 +17696,13 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +util@^0.10.3: + version "0.10.4" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901" + integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A== + dependencies: + inherits "2.0.3" + util@^0.12.0, util@^0.12.4: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" @@ -16948,7 +17770,7 @@ value-equal@^1.0.1: resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== -vary@~1.1.2: +vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== @@ -17463,6 +18285,11 @@ yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.1.1" +yarn@^1.22.19: + version "1.22.19" + resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.22.19.tgz#4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447" + integrity sha512-/0V5q0WbslqnwP91tirOvldvYISzaqhClxzyUKXYxs07yUILIs5jx/k6CFe8bvKSkds5w+eiOqta39Wk3WxdcQ== + yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" @@ -17481,7 +18308,12 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@3.21.4: +zod-to-json-schema@^3.20.0: + version "3.21.2" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.21.2.tgz#74dbbf97863c8b3f0576beb44e0af52382596fe4" + integrity sha512-02yfKymfmIf2rM/5LYGlyw0daEel/f3MsSGMNJZWWf44ato+Y+diFugOpDtgvEUn3cYM5oDAGWW2NHeSD4mByw== + +zod@3.21.4, zod@^3.21.4: version "3.21.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db" integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==