diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..c188bb3a --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +DATABASE_URL=postgresql://username:password@host:port/db_name + +RESEND_API_KEY=re_1234202md + +NEXTAUTH_SECRET=secret + +BASE_URL=http://localhost:7070 + +NEXTAUTH_URL= + +AWS_IAM_ACCESS_KEY= +AWS_IAM_SECRET_KEY= +AWS_S3_BUCKET_REGION= +AWS_S3_BUCKET_NAME= \ No newline at end of file diff --git a/.github/workflows/staging-ci.yml b/.github/workflows/staging-ci.yml new file mode 100644 index 00000000..5c1f0138 --- /dev/null +++ b/.github/workflows/staging-ci.yml @@ -0,0 +1,36 @@ +name: CI for Staging + +on: + push: + branches: + - staging + pull_request: + branches: + - staging + +jobs: + ci: + runs-on: ubuntu-latest + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: yarn install + + - name: Run lint-staged + run: yarn lint-staged + + - name: Build project + run: yarn build + + - name: Mirgate DB + run: yarn migrate diff --git a/.gitignore b/.gitignore index fd3dbb57..32e7b6b6 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -# local env files -.env*.local +# env files +.env* +!.env.example # vercel .vercel @@ -34,3 +35,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/.idx/dev.nix b/.idx/dev.nix new file mode 100644 index 00000000..cdc01bbf --- /dev/null +++ b/.idx/dev.nix @@ -0,0 +1,26 @@ +{pkgs}: { + channel = "stable-24.05"; + packages = [ + pkgs.nodejs_20 + ]; + idx.extensions = [ + + ]; + idx.previews = { + previews = { + web = { + command = [ + "npm" + "run" + "dev" + "--" + "--port" + "$PORT" + "--hostname" + "0.0.0.0" + ]; + manager = "web"; + }; + }; + }; +} \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json index a10b44f2..1f723c06 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -6,5 +6,6 @@ "trailingComma": "es5", "bracketSpacing": true, "jsxBracketSameLine": false, - "arrowParens": "always" + "arrowParens": "always", + "plugins": ["prettier-plugin-tailwindcss"] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..03adc8d2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "IDX.aI.enableInlineCompletion": true, + "IDX.aI.enableCodebaseIndexing": true +} \ No newline at end of file diff --git a/README.md b/README.md index c4033664..a27f5374 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,90 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# 45Group -## Getting Started +--- -First, run the development server: +## Project Setup + +### Install Dependencies + +After cloning the repository, install the dependencies using: + +```bash +yarn install +``` + +Ensure that Yarn is installed before running this command. If Yarn is not installed and you prefer using another package manager like npm or pnpm, follow these steps: + +1. Delete the `yarn.lock` file. +2. Run the installation command for your chosen package manager, such as `npm install` or `pnpm install`. + +### Environment Variables + +Before starting the project, ensure you have all the required environment variables. Use the `.env.example` file as a guide to set up the necessary values in your `.env` file. + +### Development + +To start the project in development mode, run: ```bash -npm run dev -# or yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +### Production + +To run the project in production mode: + +1. Build the project: + + ```bash + yarn build + ``` + +2. Start the production server: + + ```bash + yarn start + ``` + +### Code Formatting + +To format all files using Prettier, run: + +```bash +yarn format +``` + +### Linting -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +To lint all files using ESLint, run: -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. +```bash +yarn lint +``` -## Learn More +### Database -To learn more about Next.js, take a look at the following resources: +#### Migrations -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +To migrate the database, run: -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! +```bash +yarn migrate +``` -## Deploy on Vercel +**Note:** The project uses PostgreSQL as the database. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +#### Generate Migrations -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +To create migrations from your Drizzle schema, run: + +```bash +yarn generate +``` + +#### Drizzle Studio + +To run Drizzle Studio, execute: + +```bash +yarn drizzle-studio +``` diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..754191a0 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "drizzle-kit"; +import * as dotenv from "dotenv"; + +dotenv.config({ path: ".env.local" }); +dotenv.config({ path: ".env" }); + +export default defineConfig({ + schema: "./src/db/schemas", + dialect: "postgresql", + out: "./migrations", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, + verbose: true, + strict: true, +}); diff --git a/migrations/0000_mushy_garia.sql b/migrations/0000_mushy_garia.sql new file mode 100644 index 00000000..9f62632a --- /dev/null +++ b/migrations/0000_mushy_garia.sql @@ -0,0 +1,283 @@ +CREATE TABLE IF NOT EXISTS "availability" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "start_time" timestamp NOT NULL, + "end_time" timestamp NOT NULL, + "description" varchar(500), + "resource_id" uuid NOT NULL, + "status" varchar NOT NULL, + "updated_at" timestamp, + "created_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "blacklisted_token" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "token" text NOT NULL, + "blacklisted_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "bookings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "resource_id" uuid NOT NULL, + "check_in_date" timestamp NOT NULL, + "check_out_date" timestamp NOT NULL, + "status" varchar NOT NULL, + "updated_at" timestamp, + "created_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "facilities" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(150) NOT NULL, + "description" varchar, + "updated_at" timestamp, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "facilities_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "groups" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(150) NOT NULL, + "updated_at" timestamp, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "groups_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "resource_blocks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "resource_id" uuid NOT NULL, + "reason" text, + "start_time" time NOT NULL, + "end_time" time NOT NULL, + "block_type" varchar NOT NULL, + "recurring" varchar +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "resource_facilities" ( + "resource_id" uuid NOT NULL, + "facility_id" uuid NOT NULL, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "resource_facilities_resource_id_facility_id_pk" PRIMARY KEY("resource_id","facility_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "resource_groups" ( + "resource_id" uuid NOT NULL, + "group_id" uuid NOT NULL, + "num" integer NOT NULL, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "resource_groups_resource_id_group_id_pk" PRIMARY KEY("resource_id","group_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "resource_rules" ( + "resource_id" uuid NOT NULL, + "rule_id" uuid NOT NULL, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "resource_rules_resource_id_rule_id_pk" PRIMARY KEY("resource_id","rule_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "resource_schedules" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "resource_id" uuid NOT NULL, + "start_time" time NOT NULL, + "end_time" time NOT NULL, + "day_of_week" varchar NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "resources" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(300) NOT NULL, + "type" varchar NOT NULL, + "description" varchar NOT NULL, + "status" varchar DEFAULT 'draft', + "handle" varchar NOT NULL, + "location_id" uuid NOT NULL, + "schedule_type" varchar NOT NULL, + "thumbnail" varchar NOT NULL, + "updated_at" timestamp, + "created_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "locations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(300) NOT NULL, + "state" varchar(100) NOT NULL, + "city" varchar(100) NOT NULL, + "description" varchar, + "updated_at" timestamp, + "deleted_at" timestamp, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "unique_name_state_city" UNIQUE("name","state","city") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "medias" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "url" varchar NOT NULL, + "mime_type" varchar(100), + "size" integer, + "updated_at" timestamp, + "created_at" timestamp DEFAULT now(), + "metadata" jsonb, + "user_id" uuid, + "location_id" uuid, + "resource_id" uuid +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "first_name" varchar(100), + "last_name" varchar(100), + "image" varchar, + "type" varchar DEFAULT 'user', + "email" varchar(320) NOT NULL, + "phone" varchar(256), + "is_verified" boolean DEFAULT false, + "updated_at" timestamp, + "created_at" timestamp DEFAULT now(), + "last_login_at" timestamp, + "complete_profile" boolean DEFAULT false, + "metadata" json, + CONSTRAINT "users_email_unique" UNIQUE("email"), + CONSTRAINT "users_phone_unique" UNIQUE("phone") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "rules" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(150) NOT NULL, + "description" varchar, + "category" varchar NOT NULL, + "updated_at" timestamp, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "rules_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "regions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar NOT NULL, + "currency_code" varchar NOT NULL, + "deleted_at" timestamp, + "updated_at" timestamp, + "created_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "otps" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" varchar NOT NULL, + "hashed_otp" varchar(64) NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "prices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "amount" numeric NOT NULL, + "currency_code" varchar(3) NOT NULL, + "region_id" uuid NOT NULL, + "resource_id" uuid NOT NULL, + "updated_at" timestamp, + "created_at" timestamp DEFAULT now() +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "availability" ADD CONSTRAINT "availability_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "bookings" ADD CONSTRAINT "bookings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "bookings" ADD CONSTRAINT "bookings_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "resource_blocks" ADD CONSTRAINT "resource_blocks_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "resource_facilities" ADD CONSTRAINT "resource_facilities_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "resource_facilities" ADD CONSTRAINT "resource_facilities_facility_id_facilities_id_fk" FOREIGN KEY ("facility_id") REFERENCES "public"."facilities"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "resource_groups" ADD CONSTRAINT "resource_groups_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "resource_groups" ADD CONSTRAINT "resource_groups_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "resource_rules" ADD CONSTRAINT "resource_rules_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "resource_rules" ADD CONSTRAINT "resource_rules_rule_id_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."rules"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "resource_schedules" ADD CONSTRAINT "resource_schedules_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "resources" ADD CONSTRAINT "resources_location_id_locations_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."locations"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "medias" ADD CONSTRAINT "medias_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "medias" ADD CONSTRAINT "medias_location_id_locations_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."locations"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "medias" ADD CONSTRAINT "medias_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "prices" ADD CONSTRAINT "prices_region_id_regions_id_fk" FOREIGN KEY ("region_id") REFERENCES "public"."regions"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "prices" ADD CONSTRAINT "prices_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "unique_day_of_week" ON "resource_schedules" USING btree ("resource_id","day_of_week");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "unique_resource" ON "resources" USING btree ("name","type"); \ No newline at end of file diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..ca5aaae1 --- /dev/null +++ b/migrations/meta/0000_snapshot.json @@ -0,0 +1,1274 @@ +{ + "id": "7e8fc44b-7e58-4ce8-8e26-83b2f46329b7", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.availability": { + "name": "availability", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "availability_resource_id_resources_id_fk": { + "name": "availability_resource_id_resources_id_fk", + "tableFrom": "availability", + "tableTo": "resources", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.blacklisted_token": { + "name": "blacklisted_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blacklisted_at": { + "name": "blacklisted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.bookings": { + "name": "bookings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "check_in_date": { + "name": "check_in_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "check_out_date": { + "name": "check_out_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bookings_user_id_users_id_fk": { + "name": "bookings_user_id_users_id_fk", + "tableFrom": "bookings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bookings_resource_id_resources_id_fk": { + "name": "bookings_resource_id_resources_id_fk", + "tableFrom": "bookings", + "tableTo": "resources", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.facilities": { + "name": "facilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "facilities_name_unique": { + "name": "facilities_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + } + }, + "public.groups": { + "name": "groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "groups_name_unique": { + "name": "groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + } + }, + "public.resource_blocks": { + "name": "resource_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "block_type": { + "name": "block_type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "recurring": { + "name": "recurring", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "resource_blocks_resource_id_resources_id_fk": { + "name": "resource_blocks_resource_id_resources_id_fk", + "tableFrom": "resource_blocks", + "tableTo": "resources", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.resource_facilities": { + "name": "resource_facilities", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "facility_id": { + "name": "facility_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "resource_facilities_resource_id_resources_id_fk": { + "name": "resource_facilities_resource_id_resources_id_fk", + "tableFrom": "resource_facilities", + "tableTo": "resources", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_facilities_facility_id_facilities_id_fk": { + "name": "resource_facilities_facility_id_facilities_id_fk", + "tableFrom": "resource_facilities", + "tableTo": "facilities", + "columnsFrom": [ + "facility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "resource_facilities_resource_id_facility_id_pk": { + "name": "resource_facilities_resource_id_facility_id_pk", + "columns": [ + "resource_id", + "facility_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.resource_groups": { + "name": "resource_groups", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "num": { + "name": "num", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "resource_groups_resource_id_resources_id_fk": { + "name": "resource_groups_resource_id_resources_id_fk", + "tableFrom": "resource_groups", + "tableTo": "resources", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_groups_group_id_groups_id_fk": { + "name": "resource_groups_group_id_groups_id_fk", + "tableFrom": "resource_groups", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "resource_groups_resource_id_group_id_pk": { + "name": "resource_groups_resource_id_group_id_pk", + "columns": [ + "resource_id", + "group_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.resource_rules": { + "name": "resource_rules", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "resource_rules_resource_id_resources_id_fk": { + "name": "resource_rules_resource_id_resources_id_fk", + "tableFrom": "resource_rules", + "tableTo": "resources", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_rules_rule_id_rules_id_fk": { + "name": "resource_rules_rule_id_rules_id_fk", + "tableFrom": "resource_rules", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "resource_rules_resource_id_rule_id_pk": { + "name": "resource_rules_resource_id_rule_id_pk", + "columns": [ + "resource_id", + "rule_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.resource_schedules": { + "name": "resource_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "day_of_week": { + "name": "day_of_week", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "unique_day_of_week": { + "name": "unique_day_of_week", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day_of_week", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_schedules_resource_id_resources_id_fk": { + "name": "resource_schedules_resource_id_resources_id_fk", + "tableFrom": "resource_schedules", + "tableTo": "resources", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "handle": { + "name": "handle", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "schedule_type": { + "name": "schedule_type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "thumbnail": { + "name": "thumbnail", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_resource": { + "name": "unique_resource", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resources_location_id_locations_id_fk": { + "name": "resources_location_id_locations_id_fk", + "tableFrom": "resources", + "tableTo": "locations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.locations": { + "name": "locations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_name_state_city": { + "name": "unique_name_state_city", + "nullsNotDistinct": false, + "columns": [ + "name", + "state", + "city" + ] + } + } + }, + "public.medias": { + "name": "medias", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "medias_user_id_users_id_fk": { + "name": "medias_user_id_users_id_fk", + "tableFrom": "medias", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "medias_location_id_locations_id_fk": { + "name": "medias_location_id_locations_id_fk", + "tableFrom": "medias", + "tableTo": "locations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "medias_resource_id_resources_id_fk": { + "name": "medias_resource_id_resources_id_fk", + "tableFrom": "medias", + "tableTo": "resources", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "complete_profile": { + "name": "complete_profile", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_phone_unique": { + "name": "users_phone_unique", + "nullsNotDistinct": false, + "columns": [ + "phone" + ] + } + } + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rules_name_unique": { + "name": "rules_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + } + }, + "public.regions": { + "name": "regions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "currency_code": { + "name": "currency_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.otps": { + "name": "otps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "hashed_otp": { + "name": "hashed_otp", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency_code": { + "name": "currency_code", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "region_id": { + "name": "region_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "prices_region_id_regions_id_fk": { + "name": "prices_region_id_regions_id_fk", + "tableFrom": "prices", + "tableTo": "regions", + "columnsFrom": [ + "region_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_resource_id_resources_id_fk": { + "name": "prices_resource_id_resources_id_fk", + "tableFrom": "prices", + "tableTo": "resources", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json new file mode 100644 index 00000000..0e7fd08a --- /dev/null +++ b/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1741180497711, + "tag": "0000_mushy_garia", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index f14dde7e..b69612af 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,7 @@ +import { withSentryConfig } from "@sentry/nextjs"; /** @type {import('next').NextConfig} */ const nextConfig = { + transpilePackages: ["mui-tel-input"], images: { remotePatterns: [ { @@ -14,4 +16,37 @@ const nextConfig = { }, }; -export default nextConfig; +export default withSentryConfig(nextConfig, { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + org: "qbrkts", + project: "45group", + + // Only print logs for uploading source maps in CI + silent: !process.env.CI, + + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + // tunnelRoute: "/monitoring", + + // Hides source maps from generated client bundles + hideSourceMaps: true, + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, + + // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, +}); diff --git a/package.json b/package.json index 9af32c49..4023e9b5 100644 --- a/package.json +++ b/package.json @@ -6,22 +6,49 @@ "dev": "next dev -p 7070", "build": "next build", "start": "next start -p 5040", - "lint": "next lint" + "lint": "next lint --report-unused-disable-directives --max-warnings 0", + "format": "prettier --write .", + "migrate": "drizzle-kit migrate", + "generate": "drizzle-kit generate", + "drizzle-studio": "drizzle-kit studio" }, "dependencies": { + "@aws-sdk/client-s3": "^3.682.0", "@emotion/cache": "^11.13.1", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@mui/lab": "^5.0.0-alpha.173", "@mui/material": "^5.16.7", "@mui/material-nextjs": "^5.16.6", + "@mui/x-data-grid": "^7.23.6", + "@mui/x-date-pickers": "^7.19.0", + "@react-email/components": "^0.0.25", + "@sentry/nextjs": "8", "@tanstack/react-query": "^5.52.0", + "@vercel/analytics": "^1.5.0", + "axios": "^1.7.7", "clsx": "^2.1.1", + "dayjs": "^1.11.13", + "dotenv": "^16.4.5", + "drizzle-orm": "^0.33.0", "formik": "^2.4.6", + "framer-motion": "^11.3.31", + "jsonwebtoken": "^9.0.2", + "libphonenumber-js": "^1.11.12", + "lodash.debounce": "^4.0.8", + "moment": "^2.30.1", + "mui-one-time-password-input": "^2.0.3", + "mui-tel-input": "^6.0.1", "next": "14.2.5", + "next-auth": "^4.24.10", "nprogress": "^0.2.0", + "postgres": "^3.4.4", "react": "^18", "react-dom": "^18", + "react-icons": "^5.3.0", + "resend": "^4.0.0", + "sharp": "^0.33.5", + "slugify": "^1.6.6", "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "yup": "^1.4.0", @@ -29,14 +56,27 @@ }, "devDependencies": { "@tanstack/react-query-devtools": "^5.52.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/lodash.debounce": "^4.0.9", "@types/node": "^20", "@types/nprogress": "^0.2.3", "@types/react": "^18", "@types/react-dom": "^18", + "drizzle-kit": "^0.24.2", "eslint": "^8", "eslint-config-next": "14.2.5", + "lint-staged": "^15.2.9", "postcss": "^8", + "prettier": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.6", "tailwindcss": "^3.4.1", "typescript": "^5" + }, + "lint-staged": { + "*.{ts,tsx,js}": [ + "yarn lint", + "yarn format" + ], + "*.css": "yarn format" } } diff --git a/public/ads.txt b/public/ads.txt new file mode 100644 index 00000000..ff02667a --- /dev/null +++ b/public/ads.txt @@ -0,0 +1 @@ +google.com, pub-3505239651298035, DIRECT, f08c47fec0942fa0 diff --git a/sentry.client.config.ts b/sentry.client.config.ts new file mode 100644 index 00000000..0db50e3b --- /dev/null +++ b/sentry.client.config.ts @@ -0,0 +1,28 @@ +// This file configures the initialization of Sentry on the client. +// The config you add here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: "https://046dd262cf2d3e3562c54f11715c4572@o4508383705628672.ingest.de.sentry.io/4508383718342736", + + enabled: process.env.NODE_ENV === "production", + + // Add optional integrations for additional features + integrations: [Sentry.replayIntegration()], + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Define how likely Replay events are sampled. + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, + + // Define how likely Replay events are sampled when an error occurs. + replaysOnErrorSampleRate: 1.0, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts new file mode 100644 index 00000000..92d034d4 --- /dev/null +++ b/sentry.edge.config.ts @@ -0,0 +1,18 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: "https://046dd262cf2d3e3562c54f11715c4572@o4508383705628672.ingest.de.sentry.io/4508383718342736", + + enabled: process.env.NODE_ENV === "production", + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/sentry.server.config.ts b/sentry.server.config.ts new file mode 100644 index 00000000..459854a8 --- /dev/null +++ b/sentry.server.config.ts @@ -0,0 +1,17 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: "https://046dd262cf2d3e3562c54f11715c4572@o4508383705628672.ingest.de.sentry.io/4508383718342736", + + enabled: process.env.NODE_ENV === "production", + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/src/app/(auth)/complete-profile/page.tsx b/src/app/(auth)/complete-profile/page.tsx new file mode 100644 index 00000000..d0586153 --- /dev/null +++ b/src/app/(auth)/complete-profile/page.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useRef } from "react"; +import { useRouter } from "next/navigation"; +import { Avatar } from "@mui/material"; +import { Formik } from "formik"; +import * as Yup from "yup"; +import { matchIsValidTel } from "mui-tel-input"; +import nProgress from "nprogress"; +import { IoPerson } from "react-icons/io5"; +import Button from "~/components/button"; +import FormField from "~/components/fields/form-field"; +import Logo from "~/components/logo"; +import { useUpdateMe } from "~/hooks/users"; +import PhoneNumberField from "~/components/fields/phone-number-field"; +import { filterPrivateValues } from "~/utils/helpers"; + +const validationSchema = Yup.object({ + first_name: Yup.string().required("First name is required"), + last_name: Yup.string().required("Last name is required"), + phone: Yup.string() + .required("Phone number is required") + .test("valid-phone", "Please enter a valid phone number", (value) => { + if (!value) return false; + return matchIsValidTel(value); + }), +}); + +export default function CompleteProfile({ + searchParams: { origin }, +}: { + searchParams: { origin?: string }; +}) { + const imageInputRef = useRef(null); + + const { mutateAsync: updateMe } = useUpdateMe(); + + const router = useRouter(); + + return ( +
+
+ +
+
+
+

Complete Profile

+ + We need some personal infomation from you. + +
+
+ { + const submissionValues = filterPrivateValues(values); + + await updateMe( + { + ...submissionValues, + complete_profile: true, + }, + { + onSuccess: () => { + resetForm(); + nProgress.start(); + router.push(origin || "/booking"); + }, + } + ); + }} + validateOnBlur={false} + > + {({ handleSubmit, isSubmitting, setFieldValue, values }) => { + return ( +
+
+
+
+ + + +
+
+
+

Upload your photo

+ + Your photo should be in PNG, JPG or JPEG format. + +
+ +
+ { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + setFieldValue("image", file); + setFieldValue(`_image_base64`, reader.result); + }; + } + }} + /> +
+ + + +
+ +
+ ); + }} +
+
+
+
+ ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 00000000..8dcb988b --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from "react"; + +export default function Layout({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/app/(auth)/signin/page.tsx b/src/app/(auth)/signin/page.tsx new file mode 100644 index 00000000..bbdeb892 --- /dev/null +++ b/src/app/(auth)/signin/page.tsx @@ -0,0 +1,20 @@ +import Link from "next/link"; +import Logo from "~/components/logo"; +import SigninForm from "~/modules/signin/form"; + +export default function Signin({ + searchParams: { origin }, +}: { + searchParams: { origin?: string }; +}) { + return ( +
+
+ + + +
+ +
+ ); +} diff --git a/src/app/(resources)/(core)/booking/[slug]/loading.tsx b/src/app/(resources)/(core)/booking/[slug]/loading.tsx new file mode 100644 index 00000000..fc80ef06 --- /dev/null +++ b/src/app/(resources)/(core)/booking/[slug]/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
Loading...
; +} diff --git a/src/app/(resources)/(core)/booking/[slug]/page.tsx b/src/app/(resources)/(core)/booking/[slug]/page.tsx new file mode 100644 index 00000000..28c81c36 --- /dev/null +++ b/src/app/(resources)/(core)/booking/[slug]/page.tsx @@ -0,0 +1,83 @@ +import { Breadcrumbs } from "@mui/material"; +import Image from "next/image"; +import Link from "next/link"; +import Button from "~/components/button"; +import ResourceTypeChip from "~/components/resource/type-chip"; +import ResourceService from "~/services/resources"; +import { textConverter } from "~/utils/helpers"; + +export default async function BookingDetails({ params: { slug } }: { params: { slug: string } }) { + const resource = await ResourceService.getPublicResource(slug); + + console.log(resource.id); + + return ( +
+
+ + {[ + + Booking + , + + {resource.name} + , + ]} + +
+
+
+
+ Resource media +
+ {resource.medias?.length && ( +
+ {resource.medias.map(({ id, url }) => ( + Resource media + ))} +
+ )} +
+
+
+

{resource.name}

+ + + {textConverter(resource.description, 200)} + {resource.description.length > 200 && ( + + )} + +
+ +
+
+
+
+ ); +} diff --git a/src/app/(resources)/(core)/booking/page.tsx b/src/app/(resources)/(core)/booking/page.tsx new file mode 100644 index 00000000..811531cf --- /dev/null +++ b/src/app/(resources)/(core)/booking/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Suspense, useState } from "react"; +import { Skeleton } from "@mui/material"; +import Filters from "~/modules/core-layout/booking-layout/filters"; +import Header from "~/modules/core-layout/booking-layout/header"; +import BookingItems from "~/modules/booking/booking-items"; + +export default function Booking() { + const [isMobileDrawerOpen, toggleMobileDrawer] = useState(false); + + return ( +
+ + toggleMobileDrawer(false)} + /> + +
+ + +
+ + +
+ + } + > +
toggleMobileDrawer(true)} /> + + + + +
+
+ ); +} diff --git a/src/app/(resources)/(core)/layout.tsx b/src/app/(resources)/(core)/layout.tsx new file mode 100644 index 00000000..b3dc3095 --- /dev/null +++ b/src/app/(resources)/(core)/layout.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from "react"; +import Link from "next/link"; +import Logo from "~/components/logo"; +import Currency from "~/components/layout-components/currency"; +import Account from "~/components/layout-components/account"; + +export default function Layout({ children }: { children: ReactNode }) { + return ( +
+
+
+ + + +
+ + +
+
+
+ {children} +
+ ); +} diff --git a/src/app/(resources)/(dashboard)/account-settings/page.tsx b/src/app/(resources)/(dashboard)/account-settings/page.tsx new file mode 100644 index 00000000..040f62d0 --- /dev/null +++ b/src/app/(resources)/(dashboard)/account-settings/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import { Typography } from "@mui/material"; +import { useQuery } from "@tanstack/react-query"; +import UsersService from "~/services/users"; +import ChangeEmailModal from "~/modules/account-settings/change-email"; +import { useRequestOtp } from "~/hooks/auth"; + +export default function AccountSettings() { + const [openChangeEmailModal, toggleChangeEmailModal] = useState(false); + + const { mutateAsync: requestOtp } = useRequestOtp(); + + const { data: currentUser } = useQuery({ + queryKey: ["current-user"], + queryFn: UsersService.getMe, + }); + + return ( + <> +
+ Account Settings +
+

Email Address

+
+

+ Your email address is {currentUser?.email} +

+ {currentUser?.email && ( + + )} +
+
+
+ {currentUser?.email && openChangeEmailModal && ( + toggleChangeEmailModal(false)} email={currentUser.email} /> + )} + + ); +} diff --git a/src/app/(resources)/(dashboard)/layout.tsx b/src/app/(resources)/(dashboard)/layout.tsx new file mode 100644 index 00000000..d2dac895 --- /dev/null +++ b/src/app/(resources)/(dashboard)/layout.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { ReactNode, useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { IconButton } from "@mui/material"; +import { BsPerson } from "react-icons/bs"; +import { LiaBookSolid } from "react-icons/lia"; +import { IoReceiptOutline, IoSettingsOutline } from "react-icons/io5"; +import Logo from "~/components/logo"; +import Currency from "~/components/layout-components/currency"; +import Account from "~/components/layout-components/account"; +import Sidebar from "~/components/sidebar"; +import MenuIcon from "~/assets/icons/menu.svg"; + +const links = [ + { + title: "Previous Bookings", + href: "/previous-bookings", + icon: LiaBookSolid, + }, + { + title: "Receipts", + href: "/receipts", + icon: IoReceiptOutline, + }, + { + title: "Profile", + href: "/profile", + icon: BsPerson, + }, + { + title: "Settings", + icon: IoSettingsOutline, + subLinks: [ + { + title: "Account", + href: "/account-settings", + }, + ], + }, +]; + +export default function DashboardLayout({ children }: { children: ReactNode }) { + const [openSidebar, setOpenSidebar] = useState(false); + + return ( +
+
+
+
+ setOpenSidebar(true)}> + menu icon + + + + +
+
+ + +
+
+
+
+ setOpenSidebar(false)} open={openSidebar} links={links} /> +
{children}
+
+
+ ); +} diff --git a/src/app/(resources)/(dashboard)/profile/page.tsx b/src/app/(resources)/(dashboard)/profile/page.tsx new file mode 100644 index 00000000..81dac7b3 --- /dev/null +++ b/src/app/(resources)/(dashboard)/profile/page.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useMemo, useRef } from "react"; +import { Avatar, Skeleton, Typography } from "@mui/material"; +import { useQuery } from "@tanstack/react-query"; +import { Formik } from "formik"; +import * as Yup from "yup"; +import { matchIsValidTel } from "mui-tel-input"; +import { IoPerson, IoCameraOutline } from "react-icons/io5"; +import Button from "~/components/button"; +import FormField from "~/components/fields/form-field"; +import { useUpdateMe } from "~/hooks/users"; +import { notifySuccess } from "~/utils/toast"; +import UsersService from "~/services/users"; +import PhoneNumberField from "~/components/fields/phone-number-field"; +import { compareObjectValues, filterPrivateValues } from "~/utils/helpers"; + +const validationSchema = Yup.object({ + first_name: Yup.string().optional(), + last_name: Yup.string().optional(), + phone: Yup.string() + .optional() + .test("valid-phone", "Please enter a valid phone number", (value) => { + if (!value) return false; + return matchIsValidTel(value); + }), +}); + +export default function Profile() { + const profileImageInputRef = useRef(null); + + const { data: currentUser, isLoading } = useQuery({ + queryKey: ["current-user"], + queryFn: UsersService.getMe, + }); + + const { mutateAsync: updateMe } = useUpdateMe(); + + const initialValues = useMemo( + () => ({ + first_name: currentUser?.first_name || "", + last_name: currentUser?.last_name || "", + image: currentUser?.image || "", + email: currentUser?.email || "", + phone: currentUser?.phone || "", + }), + [currentUser] + ); + + if (isLoading) { + return
Loading...
; + } + + return ( +
+ My Profile + { + const submissionValues = compareObjectValues(initialValues, filterPrivateValues(values)); + + await updateMe(submissionValues, { + onSuccess: () => { + notifySuccess({ message: "Profile updated successfully" }); + }, + }); + }} + validateOnBlur={false} + enableReinitialize + > + {({ handleSubmit, isSubmitting, initialValues, setFieldValue, values }) => { + return ( +
+
+ + { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + setFieldValue("image", file); + setFieldValue(`_image_base64`, reader.result); + }; + } + }} + /> +
+
+
+ + +
+ +

+ {initialValues.email} +

+
+ +
+
+ +
+
+
+ ); + }} +
+
+ ); +} diff --git a/src/app/(resources)/admin/dashboard/page.tsx b/src/app/(resources)/admin/dashboard/page.tsx new file mode 100644 index 00000000..5bd204ad --- /dev/null +++ b/src/app/(resources)/admin/dashboard/page.tsx @@ -0,0 +1,9 @@ +import { Typography } from "@mui/material"; + +export default function AdminDashboard() { + return ( +
+ Dashboard +
+ ); +} diff --git a/src/app/(resources)/admin/layout.tsx b/src/app/(resources)/admin/layout.tsx new file mode 100644 index 00000000..7fc0f373 --- /dev/null +++ b/src/app/(resources)/admin/layout.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { ReactNode, Suspense, useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { IconButton, Avatar } from "@mui/material"; +import { useQuery } from "@tanstack/react-query"; +import { LuLayoutDashboard } from "react-icons/lu"; +import { GrResources } from "react-icons/gr"; +import { TbMap2 } from "react-icons/tb"; +import Logo from "~/components/logo"; +import Sidebar from "~/components/sidebar"; +import UsersService from "~/services/users"; +import MenuIcon from "~/assets/icons/menu.svg"; +import { cn } from "~/utils/helpers"; + +const links = [ + { + title: "Dashboard", + href: "/admin/dashboard", + icon: LuLayoutDashboard, + }, + { + title: "Resources", + href: "/admin/resources", + icon: GrResources, + }, + { + title: "Locations", + href: "/admin/locations", + icon: TbMap2, + }, +]; + +export default function AdminDashboardLayout({ children }: { children: ReactNode }) { + const [openSidebar, toggleSidebar] = useState(false); + + const { data: currentUser } = useQuery({ + queryKey: ["current-user"], + queryFn: UsersService.getMe, + }); + + return ( +
+
+
+
+ toggleSidebar(true)}> + menu icon + + + + +
+ {currentUser && ( +
+ + {`${currentUser.first_name?.[0]}${currentUser.last_name?.[0]}`} + +
+ )} +
+
+
+ toggleSidebar(false)} open={openSidebar} links={links} /> +
+ Loading...
}>{children} + +
+ + ); +} diff --git a/src/app/(resources)/admin/locations/[id]/page.tsx b/src/app/(resources)/admin/locations/[id]/page.tsx new file mode 100644 index 00000000..63c27f99 --- /dev/null +++ b/src/app/(resources)/admin/locations/[id]/page.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import Button from "~/components/button"; +import NotFound from "~/components/not-found"; +import LocationsService from "~/services/locations"; +import LocationDetailsMenu from "~/modules/location-details/menu"; +import BackButton from "~/components/back-button"; +import MediaModal from "~/components/modals/media"; +import { useDeleteLocationMedia, useUploadLocationMedia } from "~/hooks/locations"; + +export default function LocationDetails() { + const { id } = useParams<{ id: string }>(); + + const [isMediaModalOpen, setIsMediaModalOpen] = useState(false); + + const handleMediaOpen = () => setIsMediaModalOpen(true); + const handleMediaClose = () => setIsMediaModalOpen(false); + + const { data: location, isLoading } = useQuery({ + queryKey: ["locations", id], + queryFn: () => LocationsService.getLocation(id), + enabled: !!id, + }); + + const { mutateAsync: uploadMedia, isPending: isUploading } = useUploadLocationMedia(); + const { mutateAsync: deleteMedia, isPending: isDeleting } = useDeleteLocationMedia(); + + if (isLoading) { + return
Loading...
; + } + + if (!location) { + return ; + } + + return ( +
+ +
+
+
+
+

{location.name}

+ +
+
{location.description}
+
+
+
Details
+
+

+ State + {location.state} +

+

+ City + {location.city} +

+

+ Resources + {location.resources?.length || 0} +

+
+
+
+
+
+
Media
+ + { + if (media_ids.length > 0) { + await deleteMedia({ id, data: { media_ids } }); + } + + if (medias.length > 0) { + await uploadMedia({ + id, + data: { + name: location.name, + city: location.city, + state: location.state, + medias, + }, + }); + } + }} + /> +
+ {location.medias?.length ? ( +
+ {location.medias.map(({ url }, index) => ( +
+ Location image +
+ ))} +
+ ) : ( +
+

No images found

+
+ )} +
+
+
+ ); +} diff --git a/src/app/(resources)/admin/locations/create/page.tsx b/src/app/(resources)/admin/locations/create/page.tsx new file mode 100644 index 00000000..eb146f50 --- /dev/null +++ b/src/app/(resources)/admin/locations/create/page.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { MenuItem, Typography } from "@mui/material"; +import { useMutation } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import nProgress from "nprogress"; +import { Formik } from "formik"; +import * as Yup from "yup"; +import BackButton from "~/components/back-button"; +import Button from "~/components/button"; +import LocationsService from "~/services/locations"; +import { filterPrivateValues } from "~/utils/helpers"; +import { notifyError, notifySuccess } from "~/utils/toast"; +import MultiMedia from "~/components/form/multi-media"; +import FormField from "~/components/fields/form-field"; +import SelectField from "~/components/fields/select-field"; +import statesData from "~/data/states.json"; + +const validationSchema = Yup.object({ + name: Yup.string().required("Name is required"), + state: Yup.string().required("State is required"), + city: Yup.string().required("City is required"), + description: Yup.string().optional(), +}); + +type InitialValues = { + name: string; + description: string; + city: string; + media: File[]; + _media_base64: string[]; + _cities?: string[]; +}; + +const initialValues: InitialValues = { + name: "", + description: "", + city: "", + media: [], + _media_base64: [], +}; + +export default function CreateLocation() { + const router = useRouter(); + + const { mutateAsync: createLocation } = useMutation({ + mutationFn: LocationsService.createLocation, + onError: (error) => { + if (isAxiosError(error)) { + const data = error.response?.data; + if (data.errors) { + return notifyError({ message: data.errors[0].message }); + } + notifyError({ message: data.error }); + } + }, + }); + + return ( +
+
+ +
+ { + const submissionValues = filterPrivateValues(values); + + if (!media.length) return notifyError({ message: "At least one media must be uploaded" }); + await createLocation( + { ...submissionValues, images: media }, + { + onSuccess: () => { + notifySuccess({ message: "Location successfully created" }); + nProgress.start(); + router.push("/admin/locations"); + resetForm(); + }, + } + ); + }} + // enableReinitialize + validationSchema={validationSchema} + validateOnBlur={false} + > + {({ handleSubmit, isSubmitting, setFieldValue, values }) => ( +
+
+ Create Location + +
+
+
+ +
+ + {statesData.map(({ state, cities }, index) => ( + setFieldValue("_cities", cities)} + > + {state} + + ))} + + + {values._cities ? ( + values._cities.map((city, index) => ( + + {city} + + )) + ) : ( + + State has not been selected. + + )} + +
+ +
+
+ + values={values} + setFieldValue={setFieldValue} + title="Media" + description="Add images of the location." + /> +
+
+
+ )} +
+
+ ); +} diff --git a/src/app/(resources)/admin/locations/page.tsx b/src/app/(resources)/admin/locations/page.tsx new file mode 100644 index 00000000..eb2dc8f2 --- /dev/null +++ b/src/app/(resources)/admin/locations/page.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { Suspense, useCallback } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Typography } from "@mui/material"; +import { GridColDef } from "@mui/x-data-grid"; +import { Formik } from "formik"; +import moment from "moment"; +import nProgress from "nprogress"; +import { TbTrash } from "react-icons/tb"; +import { FiEdit, FiSearch } from "react-icons/fi"; +import Button from "~/components/button"; +import DataGrid from "~/components/data-grid"; +import { useCustomSearchParams } from "~/hooks/utils"; +import LocationsService from "~/services/locations"; +import { Location } from "~/db/schemas/locations"; +import FormField from "~/components/fields/form-field"; +import usePrompt from "~/hooks/prompt"; +import { useDeleteLocation } from "~/hooks/locations"; +import LocationFilter from "~/modules/locations/filter"; + +const columns: GridColDef[] = [ + { + field: "name", + headerName: "Name", + minWidth: 200, + flex: 1, + }, + { + field: "state", + headerName: "State", + minWidth: 200, + flex: 1, + }, + { + field: "city", + headerName: "City", + minWidth: 200, + flex: 1, + }, + { + field: "created_at", + headerName: "CREATED AT", + minWidth: 200, + flex: 1, + valueFormatter: (value) => { + return moment(value).format("MMMM D, YYYY"); + }, + sortComparator: (v1: string, v2: string) => { + return new Date(v1).getTime() - new Date(v2).getTime(); + }, + }, +]; + +export default function Locations() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); + + const { q, limit, offset } = useCustomSearchParams(["q", "limit", "offset"]); + + const { data: locations, isLoading } = useQuery({ + queryKey: ["locations", { limit, offset, q }], + queryFn: () => { + const params = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + q, + }); + return LocationsService.getLocations({ params }); + }, + }); + + const { mutateAsync: deleteLocation, isPending: isDeleting } = useDeleteLocation(); + + const prompt = usePrompt(); + + const handleDelete = useCallback( + async (id: string) => { + const confirmed = await prompt({ + title: "Please confirm", + description: "Are you sure you want delete this location?", + isLoading: isDeleting, + }); + + if (confirmed) { + await deleteLocation(id); + } + }, + [deleteLocation, isDeleting, prompt] + ); + + function goToDetails(id: string) { + nProgress.start(); + router.push(`/admin/locations/${id}`); + } + + return ( +
+
+ Locations + +
+
+
+

All Locations

+
+
+
+ { + const params = new URLSearchParams(searchParams); + for (const [key, value] of Object.entries(values)) { + if (value) params.set(key, value); + } + window.history.replaceState(null, "", `${pathname}?${params.toString()}`); + }} + enableReinitialize + > + {({ handleSubmit, submitForm }) => ( +
+ } + placeholder="Search for location..." + className="w-full max-w-[350px]" + onKeyDown={(e) => { + if (e.key === "Enter") { + submitForm(); + } + }} + /> + + )} +
+ +
+
+ + + rows={locations?.data} + loading={isLoading} + columns={columns} + rowCount={locations?.count} + onRowClick={({ id }) => goToDetails(id as string)} + menuComp={({ row: { id } }) => { + return ( + <> + + + + ); + }} + /> + +
+
+
+
+ ); +} diff --git a/src/app/(resources)/admin/resources/[id]/page.tsx b/src/app/(resources)/admin/resources/[id]/page.tsx new file mode 100644 index 00000000..1fe1578b --- /dev/null +++ b/src/app/(resources)/admin/resources/[id]/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import BackButton from "~/components/back-button"; +import NotFound from "~/components/not-found"; +import ResourceService from "~/services/resources"; +import ResourceDetailsMenu from "~/modules/resource-details/menu"; +import ResourceStatus from "~/modules/resource-details/status"; +import ResourceTypeChip from "~/components/resource/type-chip"; +import ResourceMediaSection from "~/modules/resource-details/media-section"; +import RulesSection from "~/modules/resource-details/rules-section"; +import ResourceThumbnailSection from "~/modules/resource-details/thumbnail-section"; + +export default function ResourceDetails() { + const { id } = useParams<{ id: string }>(); + + const { data: resource, isLoading } = useQuery({ + queryKey: ["resources", id], + queryFn: () => ResourceService.getResource(id), + enabled: !!id, + }); + + if (isLoading) { + return
Loading...
; + } + + if (!resource) { + return ; + } + + return ( +
+ +
+
+
+
+
+

{resource.name}

+
+ + +
+
+
+                {resource.description}
+              
+
+
+
Details
+
+
+ Type + + + +
+
+ Handle + {resource.handle || "-"} +
+
+ Availability + {resource.schedule_type} +
+
+ Location + {resource.location?.name} +
+
+
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/src/app/(resources)/admin/resources/create/page.tsx b/src/app/(resources)/admin/resources/create/page.tsx new file mode 100644 index 00000000..e768f850 --- /dev/null +++ b/src/app/(resources)/admin/resources/create/page.tsx @@ -0,0 +1,509 @@ +"use client"; + +import { useRef } from "react"; +import { useRouter } from "next/navigation"; +import { MenuItem, Typography } from "@mui/material"; +import { Formik, FormikHelpers } from "formik"; +import { useQueries } from "@tanstack/react-query"; +import * as Yup from "yup"; +import BackButton from "~/components/back-button"; +import FormField from "~/components/fields/form-field"; +import SelectField from "~/components/fields/select-field"; +import MediaCard from "~/components/form/media-card"; +import { + cn, + filterPrivateValues, + processDeletedItems, + processExistingItems, + readFileAsBase64, +} from "~/utils/helpers"; +import { notifyError, notifySuccess } from "~/utils/toast"; +import FileUploadCard from "~/components/form/file-upload-card"; +import AvailabitySection from "~/modules/create-resource/availability-section"; +import FacilitiesSection from "~/modules/create-resource/facilities-section"; +import RulesSection from "~/modules/create-resource/rules-section"; +import Button from "~/components/button"; +import GroupsSection from "~/modules/create-resource/groups-section"; +import LocationForm from "~/modules/create-resource/location-form"; +import MultiMedia from "~/components/form/multi-media"; +import { + useAddResourceFacility, + useAddResourceGroup, + useAddResourceRule, + useCreateResource, + useUpdateResource, + useUploadResourceMedia, +} from "~/hooks/resources"; +import { DAY_OF_WEEK } from "~/utils/constants"; +import { + AvailabilityFormValues, + FacilityFormValues, + GroupFormValues, + ResourceFormValues, + RuleFormValues, +} from "~/types/resource"; +import RuleService from "~/services/rules"; +import FacilityService from "~/services/facilities"; +import GroupService from "~/services/groups"; +import { useCreateFacility, useDeleteFacility } from "~/hooks/facilities"; +import { useCreateRule, useDeleteRule } from "~/hooks/rules"; +import { useCreateGroup, useDeleteGroup } from "~/hooks/groups"; + +const initialValues: ResourceFormValues = { + name: "", + description: "", + type: "lodge", + location: null, + media: [], + availability_form: { + schedule_type: "24/7", + custom: Object.fromEntries( + DAY_OF_WEEK.map((day) => [ + day, + { + start_time: "12:00 AM", + end_time: "11:59 PM", + }, + ]) + ) as AvailabilityFormValues["custom"], + weekdays: { + start_time: "12:00 AM", + end_time: "11:59 PM", + }, + weekends: { + start_time: "12:00 AM", + end_time: "11:59 PM", + }, + }, + rule_form: { + rules: {}, + _rule: { + name: "", + description: "", + category: "house_rules", + }, + }, + facility_form: { + facilities: {}, + _facility: { + name: "", + description: "", + }, + }, + group_form: { + groups: {}, + _group: "", + }, + publish: false, + _media_base64: [], +}; + +const validationSchema = Yup.object({ + name: Yup.string().required("Name is required"), + description: Yup.string().required("Description is required"), + type: Yup.string().oneOf(["lodge", "event", "dining"]).required("Resource type is required"), +}); + +export default function CreateResource() { + const thumbnailInputRef = useRef(null); + const router = useRouter(); + + const [ + { data: rules, isLoading: isRulesLoading }, + { data: facilities, isLoading: isFacilitiesLoading }, + { data: groups, isLoading: isGroupsLoading }, + ] = useQueries({ + queries: [ + { + queryKey: ["rules"], + queryFn: RuleService.getRules, + }, + { + queryKey: ["facilities"], + queryFn: FacilityService.getFacilities, + }, + { + queryKey: ["groups"], + queryFn: GroupService.getGroups, + }, + ], + }); + + const handleThumbnailSelect = async ( + files: FileList | null, + setFieldValue: FormikHelpers["setFieldValue"] + ) => { + const file = files?.[0]; + if (file) { + try { + const base64 = await readFileAsBase64(file); + setFieldValue("thumbnail", file); + setFieldValue("_thumbnail_base64", base64); + } catch (error) { + notifyError({ + message: "Failed to process thumbnail image", + }); + } + } + }; + + const { mutateAsync: createResource } = useCreateResource(); + const { mutateAsync: updateResource } = useUpdateResource(); + const { mutateAsync: uploadResourceMedia } = useUploadResourceMedia(); + + const { mutateAsync: addRule } = useAddResourceRule(); + const { mutateAsync: addFacility } = useAddResourceFacility(); + const { mutateAsync: addGroup } = useAddResourceGroup(); + + const { mutateAsync: deleteFacility } = useDeleteFacility(); + const { mutateAsync: deleteRule } = useDeleteRule(); + const { mutateAsync: deleteGroup } = useDeleteGroup(); + + const { mutateAsync: createFacility } = useCreateFacility(); + const { mutateAsync: createRule } = useCreateRule(); + const { mutateAsync: createGroup } = useCreateGroup(); + + return ( +
+
+ +
+ ({ + ...acc, + [facility.name]: facility, + }), + {} + ), + }, + rule_form: { + ...initialValues.rule_form, + rules: rules?.reduce( + (acc, rule) => ({ + ...acc, + [rule.name]: rule, + }), + {} + ), + }, + group_form: { + ...initialValues.group_form, + groups: groups?.reduce( + (acc, group) => ({ + ...acc, + [group.name]: { + ...group, + value: 0, + }, + }), + {} + ), + }, + } as ResourceFormValues + } + onSubmit={async (values) => { + const { + rule_form: { rules }, + facility_form: { facilities }, + group_form: { groups }, + media: medias, + location, + thumbnail, + availability_form: { schedule_type, custom, weekdays, weekends }, + ...submissionValues + } = filterPrivateValues(values); + + if (!thumbnail) return notifyError({ message: "Thumbnail is required" }); + if (!medias.length) return notifyError({ message: "At least one media is required" }); + if (!location) return notifyError({ message: "Location not chosen" }); + + let schedules: Record<"start_time" | "end_time" | "day_of_week", string>[] = []; + + switch (schedule_type) { + case "custom": + schedules = Object.entries(custom).map(([key, value]) => ({ + ...value, + day_of_week: key, + })); + break; + case "weekdays": + schedules = DAY_OF_WEEK.slice(0, 5).map((day) => ({ ...weekdays, day_of_week: day })); + case "weekends": + schedules = DAY_OF_WEEK.slice(-2).map((day) => ({ ...weekends, day_of_week: day })); + } + + const { id: resourceId } = await createResource({ + ...submissionValues, + thumbnail, + schedule_type, + location_id: location?.id, + schedules, + }); + + await uploadResourceMedia({ + id: resourceId, + data: { medias }, + }); + + const deletedRules = processDeletedItems<(typeof rules)[number]>(rules); + const deletedFacilities = processDeletedItems<(typeof facilities)[number]>(facilities); + const deletedGroups = processDeletedItems(groups); + + const newRules = rules + ? Object.entries(rules).filter(([_, { id, checked }]) => !id && checked) + : []; + const newFacilities = facilities + ? Object.entries(facilities).filter(([_, { id, checked }]) => !id && checked) + : []; + const newGroups = groups ? Object.entries(groups).filter(([_, { id }]) => !id) : []; + + const oldRules = processExistingItems<(typeof rules)[number]>(rules); + const oldFacilities = processExistingItems<(typeof facilities)[number]>(facilities); + const oldGroups = processExistingItems(groups); + + console.log(oldRules, oldFacilities, oldGroups, "oldies"); + + await Promise.all([ + ...(deletedRules.length > 0 + ? deletedRules.map(([_, rule]) => rule.id && deleteRule(rule.id)) + : []), + ...(deletedFacilities.length > 0 + ? deletedFacilities.map(([_, facility]) => facility.id && deleteFacility(facility.id)) + : []), + ...(deletedGroups.length > 0 + ? deletedGroups.map(([_, group]) => group.id && deleteGroup(group.id)) + : []), + ]); + + const [rulesRes, facilitiesRes, groupsRes] = await Promise.all([ + newRules.length + ? Promise.all( + newRules.map(([_, { name, description, category }]) => + createRule({ name, category, description }) + ) + ) + : Promise.resolve([]), + newFacilities.length + ? Promise.all( + newFacilities.map(([_, { name, description }]) => + createFacility({ name, description }) + ) + ) + : Promise.resolve([]), + newGroups.length + ? Promise.all(newGroups.map(([key]) => createGroup({ name: key }))) + : Promise.resolve([]), + ]); + + const ruleIds = [ + ...oldRules.map(([_, { id }]) => id), + ...rulesRes.filter(Boolean).map(({ id }) => id), + ]; + + const facilityIds = [ + ...oldFacilities.map(([_, { id }]) => id), + ...facilitiesRes.filter(Boolean).map(({ id }) => id), + ]; + + const groupIds = [ + ...oldGroups.map(([_, { id, value: num }]) => ({ id, num })), + ...groupsRes.filter(Boolean).map(({ id, name }) => ({ + id, + num: groups?.[name]?.["value"] || 0, + })), + ]; + + await Promise.all([ + rulesRes.length + ? addRule({ id: resourceId, data: { rule_ids: ruleIds as string[] } }) + : Promise.resolve(), + facilitiesRes.length + ? addFacility({ id: resourceId, data: { facility_ids: facilityIds as string[] } }) + : Promise.resolve(), + groupsRes.length + ? addGroup({ + id: resourceId, + data: { group_ids: groupIds as { id: string; num: number }[] }, + }) + : Promise.resolve(), + ]); + + notifySuccess({ message: "Resource successfully created" }); + router.push("/admin/resources"); + }} + enableReinitialize + validationSchema={validationSchema} + validateOnBlur={false} + > + {({ handleSubmit, setFieldValue, values, isSubmitting, setFieldError }) => ( +
+
+ Create Resource + {values.location ? ( +
+ + +
+ ) : ( +
+ +
+ )} +
+ {values.location ? ( +
+
+
+
{values.location.name}
+ +
+

+ {values.location.city}, {values.location.state} +

+
+
+
+ + + + Lodge + Event + Dining + + { + setFieldValue(`rule_form.${String(field)}`, value); + }} + setFieldError={(field: keyof RuleFormValues, message: string) => { + setFieldError(`rule_form.${String(field)}`, message); + }} + /> + { + setFieldValue(`facility_form.${String(field)}`, value); + }} + setFieldError={(field: keyof FacilityFormValues, message: string) => { + setFieldError(`facility_form.${String(field)}`, message); + }} + /> + { + setFieldValue(`availability_form.${String(field)}`, message); + }} + /> + { + setFieldValue(`group_form.${String(field)}`, message); + }} + setFieldError={(field: keyof GroupFormValues, message: string) => { + setFieldError(`group_form.${String(field)}`, message); + }} + /> +
+
+
+ handleThumbnailSelect(files, setFieldValue)} + /> + {values.thumbnail && values._thumbnail_base64 && ( +
+ { + setFieldValue("thumbnail", undefined); + setFieldValue("_thumbnail_base64", undefined); + }} + /> +
+ )} +
+
+ + values={values} + setFieldValue={setFieldValue} + title="Media" + description="Add images of resource." + /> +
+
+
+
+ ) : ( + + )} + + )} +
+
+ ); +} diff --git a/src/app/(resources)/admin/resources/page.tsx b/src/app/(resources)/admin/resources/page.tsx new file mode 100644 index 00000000..bf0127f7 --- /dev/null +++ b/src/app/(resources)/admin/resources/page.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { Suspense, useCallback } from "react"; +import Image from "next/image"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { Avatar, Chip, Typography } from "@mui/material"; +import { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { useQuery } from "@tanstack/react-query"; +import { Formik } from "formik"; +import nProgress from "nprogress"; +import { FiEdit, FiSearch } from "react-icons/fi"; +import { TbTrash } from "react-icons/tb"; +import { MdOutlineBedroomChild, MdEvent, MdRestaurantMenu } from "react-icons/md"; +import Button from "~/components/button"; +import DataGrid from "~/components/data-grid"; +import FormField from "~/components/fields/form-field"; +import { useCustomSearchParams } from "~/hooks/utils"; +import ResourceService from "~/services/resources"; +import { Resource } from "~/db/schemas/resources"; +import { cn } from "~/utils/helpers"; +import usePrompt from "~/hooks/prompt"; +import { useDeleteResource } from "~/hooks/resources"; +import ResourceTypeChip from "~/components/resource/type-chip"; + +const columns: GridColDef[] = [ + { + field: "__", + headerName: "", + sortable: false, + minWidth: 10, + renderCell: ({ row: { name, thumbnail } }) => { + return ( +
+
+ {`${name}`} +
+
+ ); + }, + }, + { + field: "name", + headerName: "Name", + minWidth: 200, + flex: 1, + }, + { + field: "type", + headerName: "Type", + minWidth: 200, + flex: 1, + renderCell: ({ value }: GridRenderCellParams) => + value && , + }, + { + field: "status", + headerName: "Status", + minWidth: 200, + flex: 1, + renderCell: ({ row: { status } }) => { + if (status === "published") { + return ; + } + return ; + }, + }, + { + field: "schedule_type", + headerName: "Availability", + minWidth: 200, + flex: 1, + }, +]; + +const cards = [ + { + title: "Rooms", + icon: , + status: { + draft: 50, + published: 100, + }, + }, + { + title: "Events", + icon: , + status: { + draft: 50, + published: 100, + }, + }, + { + title: "Dining", + icon: , + status: { + draft: 50, + published: 100, + }, + }, +]; + +export default function Resources() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); + + const { q, limit, offset } = useCustomSearchParams(["q", "limit", "offset"]); + + const { data: resources, isLoading } = useQuery({ + queryKey: ["resources", { limit, offset, q }], + queryFn: () => { + const params = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + q, + }); + return ResourceService.getResources({ params }); + }, + }); + + const { mutateAsync: deleteResource, isPending: isDeleting } = useDeleteResource(); + + const prompt = usePrompt(); + + const handleDelete = useCallback( + async (id: string) => { + const confirmed = await prompt({ + title: "Please confirm", + description: "Are you sure you want delete this resource?", + isLoading: isDeleting, + }); + + if (confirmed) { + await deleteResource(id); + } + }, + [deleteResource, isDeleting, prompt] + ); + + function goToDetails(id: string) { + nProgress.start(); + router.push(`/admin/resources/${id}`); + } + + return ( +
+
+ Resources + +
+
+
+ {cards.map(({ title, icon, status }, index) => ( +
+
+ {icon} + {title} +
+
+
+
Draft
+

{status.draft}

+
+
+
Published
+

{status.published}

+
+
+
+ ))} +
+
+
+

All Resources

+
+
+ { + const params = new URLSearchParams(searchParams); + for (const [key, value] of Object.entries(values)) { + params.set(key, value); + } + window.history.replaceState(null, "", `${pathname}?${params.toString()}`); + }} + > + {({ handleSubmit, submitForm }) => ( +
+ } + placeholder="Search for resources..." + className="max-w-[300px]" + onKeyDown={(e) => { + if (e.key === "Enter") { + submitForm(); + } + }} + /> + + )} +
+
+ + goToDetails(id as string)} + menuComp={({ row: { id } }) => ( + <> + + + + )} + /> + +
+
+
+
+
+ ); +} diff --git a/src/app/(static)/(home)/@contact/page.tsx b/src/app/(static)/(home)/@contact/page.tsx new file mode 100644 index 00000000..4c4ed7da --- /dev/null +++ b/src/app/(static)/(home)/@contact/page.tsx @@ -0,0 +1,30 @@ +import Link from "next/link"; +import SectionWrapper from "~/components/home/section-wrapper"; + +const tel = "+234 8174683545"; +const email = "info@45group.org"; + +export default function Contact() { + return ( + +
+
+
Email
+ + {email} + +
+
+
Phone
+ + {tel} + +
+
+
+ ); +} diff --git a/src/app/(static)/(home)/@cuisine/page.tsx b/src/app/(static)/(home)/@cuisine/page.tsx new file mode 100644 index 00000000..696e4f78 --- /dev/null +++ b/src/app/(static)/(home)/@cuisine/page.tsx @@ -0,0 +1,75 @@ +import SectionWrapper from "~/components/home/section-wrapper"; +import StaticCard from "~/components/static-card"; + +//? Abuja - Guest 45 +import AbujaGuest451 from "~/assets/images/cuisines/abuja/guest-45/dji_0345.jpg"; +import AbujaGuest452 from "~/assets/images/cuisines/abuja/guest-45/dji_0349.jpg"; +import AbujaGuest453 from "~/assets/images/cuisines/abuja/guest-45/dji_0358.jpg"; +import AbujaGuest454 from "~/assets/images/cuisines/abuja/guest-45/dji_0362.jpg"; + +//? Calabar - Resturant 45 +import CalabarResturant451 from "~/assets/images/cuisines/calabar/resturant-45/img_1048.jpg"; +import CalabarResturant452 from "~/assets/images/cuisines/calabar/resturant-45/img_1258.jpg"; +import CalabarResturant453 from "~/assets/images/cuisines/calabar/resturant-45/img_1268.jpg"; +import CalabarResturant454 from "~/assets/images/cuisines/calabar/resturant-45/img_1272.jpg"; +import CalabarResturant455 from "~/assets/images/cuisines/calabar/resturant-45/img_1285.jpg"; +import CalabarResturant456 from "~/assets/images/cuisines/calabar/resturant-45/img_1321.jpg"; +import CalabarResturant457 from "~/assets/images/cuisines/calabar/resturant-45/img_1328.jpg"; + +//? Ikom - Pool Bar +import IkomPoolBar1 from "~/assets/images/cuisines/ikom/pool-bar/img_0031.jpg"; +import IkomPoolBar2 from "~/assets/images/cuisines/ikom/pool-bar/img_0043.jpg"; +import IkomPoolBar3 from "~/assets/images/cuisines/ikom/pool-bar/img_0068.jpg"; +import IkomPoolBar4 from "~/assets/images/cuisines/ikom/pool-bar/img_0277.jpg"; +import IkomPoolBar5 from "~/assets/images/cuisines/ikom/pool-bar/img_0372.jpg"; +import IkomPoolBar6 from "~/assets/images/cuisines/ikom/pool-bar/img_9086.jpg"; +import IkomPoolBar7 from "~/assets/images/cuisines/ikom/pool-bar/img_9987.jpg"; + +const cuisines = [ + { + images: [AbujaGuest451, AbujaGuest452, AbujaGuest453, AbujaGuest454], + link: "/booking?type=dining&city=abuja", + name: "Guest 45", + location: "Abuja", + }, + { + images: [ + CalabarResturant452, + CalabarResturant455, + CalabarResturant454, + CalabarResturant453, + CalabarResturant451, + CalabarResturant456, + CalabarResturant457, + ], + link: "/booking?type=dining&city=calabar", + name: "Resturant 45", + location: "Calabar", + }, + { + images: [ + IkomPoolBar1, + IkomPoolBar2, + IkomPoolBar3, + IkomPoolBar4, + IkomPoolBar5, + IkomPoolBar6, + IkomPoolBar7, + ], + link: "/booking?type=dining&city=ikom", + name: "Resturant/Pool Bar", + location: "Ikom", + }, +]; + +export default function Cuisine() { + return ( + +
+ {cuisines.map((cuisine, index) => ( + + ))} +
+
+ ); +} diff --git a/src/app/(static)/(home)/@events/page.tsx b/src/app/(static)/(home)/@events/page.tsx new file mode 100644 index 00000000..4450fc41 --- /dev/null +++ b/src/app/(static)/(home)/@events/page.tsx @@ -0,0 +1,56 @@ +import Image from "next/image"; +import Link from "next/link"; +import SectionWrapper from "~/components/home/section-wrapper"; +import EventImage from "~/assets/images/events/calabar/event-45/img_1375.jpg"; +import CuisineImage from "~/assets/images/cuisines/abuja/guest-45/dji_0358.jpg"; +import LodgeImage from "~/assets/images/lodges/ikom/hotel-45/img_9742.jpg"; + +export default function Events() { + return ( + +
+ + Image of an event +
+ +
+

+ Hosting events across a variety of stunning locations. Immerse yourself in unique + moments that linger in your mind, creating memories that will last a lifetime. +

+
+ + Image of an cuisine +
+ + + Image of an lodge +
+ +
+
+
+
+ ); +} diff --git a/src/app/(static)/(home)/@lodges/page.tsx b/src/app/(static)/(home)/@lodges/page.tsx new file mode 100644 index 00000000..0e3c9167 --- /dev/null +++ b/src/app/(static)/(home)/@lodges/page.tsx @@ -0,0 +1,50 @@ +import Image from "next/image"; +import Link from "next/link"; +import SectionWrapper from "~/components/home/section-wrapper"; +import AbujaGuest45 from "~/assets/images/lodges/abuja/guest-45/DSC7856.jpg"; +import CalabarHotel45 from "~/assets/images/lodges/calabar/hotel-45/img_0604.jpg"; +import IkomHotel45 from "~/assets/images/lodges/ikom/hotel-45/img_9742.jpg"; + +const data = [ + { + image: AbujaGuest45, + href: "/booking?type=rooms&city=abuja", + location: "Abuja", + }, + { + image: CalabarHotel45, + href: "/booking?type=rooms&city=calabar", + location: "Calabar", + }, + { + image: IkomHotel45, + href: "/booking?type=rooms&city=ikom", + location: "Ikom", + }, +]; + +export default function Lodges() { + return ( + +
+ {data.map(({ image, location, href }, index) => ( + + Image of lodge +
+

+ {location} +

+ + ))} +
+
+ ); +} diff --git a/src/app/(static)/(home)/layout.tsx b/src/app/(static)/(home)/layout.tsx new file mode 100644 index 00000000..b918b44c --- /dev/null +++ b/src/app/(static)/(home)/layout.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from "react"; + +type Props = Record<"children" | "lodges" | "events" | "cuisine" | "contact", ReactNode>; + +export default function Layout({ children, lodges, events, cuisine, contact }: Props) { + return ( +
+ {children} + {lodges} + {events} + {cuisine} + {contact} +
+ ); +} diff --git a/src/app/(static)/(home)/page.tsx b/src/app/(static)/(home)/page.tsx new file mode 100644 index 00000000..a0e28fdf --- /dev/null +++ b/src/app/(static)/(home)/page.tsx @@ -0,0 +1,19 @@ +import Image from "next/image"; +import Lodge from "~/assets/images/home/intro/lodges.jpg"; + +export default function Home() { + return ( +
+ Images of lodges, events and cuisine +
+

+ All In One +

+
+ ); +} diff --git a/src/app/(static)/about/page.tsx b/src/app/(static)/about/page.tsx new file mode 100644 index 00000000..d68e6996 --- /dev/null +++ b/src/app/(static)/about/page.tsx @@ -0,0 +1,47 @@ +const data = [ + "Welcome to 45 Group, where hospitality isn’t just a service—it's a heartfelt experience. Guided by our motto, “Where the heart is,” we’ve cultivated a diverse range of hospitality businesses that embody warmth, luxury, and personalized care.", + "From cozy boutique hotels to lively bars, elegant lounges, exclusive clubs, and versatile event centers, each of our establishments is designed to create memorable experiences that resonate with the heart and soul. Our venues are not just places to visit; they are destinations where moments are cherished, and stories are made.", + "Hotels: Our boutique hotels are a serene escape from the bustle of everyday life. Nestled in prime locations, each hotel offers a unique blend of comfort, luxury, and a touch of home. Whether you’re here for business, leisure, or a special occasion, our suites, penthouses, and rooms are tailored to provide an unparalleled stay that feels like home—away from home.", + "Restaurants: Indulge in a culinary journey at our restaurants, where ethnic and exotic flavors come together in perfect harmony. From street food-inspired dishes to gourmet delicacies, our menu caters to every palate, ensuring that every meal is a feast for the senses. Our 24-hour service ensures that you can satisfy your cravings anytime, day or night.", + "Event Centers: Celebrate life’s milestones with us. Our event centers are equipped with state-of-the-art facilities, offering the perfect setting for weddings, conferences, parties, and more. With customizable seating, top-notch catering, and a dedicated team to bring your vision to life, your event is in good hands.", + "Club Lounges: Sip, savor, and socialize in style at our sophisticated bars and lounges. Whether you’re unwinding after a long day, enjoying live music, or celebrating with friends, our signature drinks and vibrant atmospheres make every visit special. Don’t miss our themed nights and exclusive offers that turn ordinary evenings into extraordinary memories.", + "At 45 Group, we believe that every guest deserves a place that feels like home. With us, it’s personal. It’s comfortable. It’s unforgettable.", + "Welcome to 45 Group — Where the heart is.", +]; + +export default function About() { + return ( +
+

WHERE THE HEART IS

+
+
+ {data.map((text, index) => ( +

+ {text} +

+ ))} +
+
+

The Team

+
+
+

Precious Ogbizi

+ Managing Director +
+
+

Catherine Ogbizi

+ Executive Director +
+
+

Emmanuel Ogbizi

+ ICT Director +
+
+
+
+
+ ); +} diff --git a/src/app/(static)/cuisine/page.tsx b/src/app/(static)/cuisine/page.tsx new file mode 100644 index 00000000..f3783f67 --- /dev/null +++ b/src/app/(static)/cuisine/page.tsx @@ -0,0 +1,83 @@ +import StaticCard from "~/components/static-card"; + +//? Abuja - Guest 45 +import AbujaGuest451 from "~/assets/images/cuisines/abuja/guest-45/dji_0345.jpg"; +import AbujaGuest452 from "~/assets/images/cuisines/abuja/guest-45/dji_0349.jpg"; +import AbujaGuest453 from "~/assets/images/cuisines/abuja/guest-45/dji_0358.jpg"; +import AbujaGuest454 from "~/assets/images/cuisines/abuja/guest-45/dji_0362.jpg"; + +//? Calabar - Resturant 45 +import CalabarResturant451 from "~/assets/images/cuisines/calabar/resturant-45/img_1048.jpg"; +import CalabarResturant452 from "~/assets/images/cuisines/calabar/resturant-45/img_1258.jpg"; +import CalabarResturant453 from "~/assets/images/cuisines/calabar/resturant-45/img_1268.jpg"; +import CalabarResturant454 from "~/assets/images/cuisines/calabar/resturant-45/img_1272.jpg"; +import CalabarResturant455 from "~/assets/images/cuisines/calabar/resturant-45/img_1285.jpg"; +import CalabarResturant456 from "~/assets/images/cuisines/calabar/resturant-45/img_1321.jpg"; +import CalabarResturant457 from "~/assets/images/cuisines/calabar/resturant-45/img_1328.jpg"; + +//? Ikom - Pool Bar +import IkomPoolBar1 from "~/assets/images/cuisines/ikom/pool-bar/img_0031.jpg"; +import IkomPoolBar2 from "~/assets/images/cuisines/ikom/pool-bar/img_0043.jpg"; +import IkomPoolBar3 from "~/assets/images/cuisines/ikom/pool-bar/img_0068.jpg"; +import IkomPoolBar4 from "~/assets/images/cuisines/ikom/pool-bar/img_0277.jpg"; +import IkomPoolBar5 from "~/assets/images/cuisines/ikom/pool-bar/img_0372.jpg"; +import IkomPoolBar6 from "~/assets/images/cuisines/ikom/pool-bar/img_9086.jpg"; +import IkomPoolBar7 from "~/assets/images/cuisines/ikom/pool-bar/img_9987.jpg"; + +const cuisines = [ + { + images: [AbujaGuest451, AbujaGuest452, AbujaGuest453, AbujaGuest454], + link: "/booking?type=dining&city=abuja", + name: "Guest 45", + location: "Abuja", + }, + { + images: [ + CalabarResturant451, + CalabarResturant452, + CalabarResturant453, + CalabarResturant454, + CalabarResturant455, + CalabarResturant456, + CalabarResturant457, + ], + link: "/booking?type=dining&city=calabar", + name: "Resturant 45", + location: "Calabar", + }, + { + images: [ + IkomPoolBar1, + IkomPoolBar2, + IkomPoolBar3, + IkomPoolBar4, + IkomPoolBar5, + IkomPoolBar6, + IkomPoolBar7, + ], + link: "/booking?type=dining&city=ikom", + name: "Resturant/Pool Bar", + location: "Ikom", + }, +]; + +export default function Cuisines() { + return ( +
+
+

Cuisine

+

+ Embark on a culinary journey where the rich traditions of the past meet the bold + innovations of the present. Every dish is a masterful creation, telling a unique story of + flavor, passion, and craftsmanship. From the first bite to the last, experience a symphony + of tastes that celebrates both the heritage and the artistry of fine cuisine. +

+
+
+ {cuisines.map((cuisine, index) => ( + + ))} +
+
+ ); +} diff --git a/src/app/(static)/events/page.tsx b/src/app/(static)/events/page.tsx new file mode 100644 index 00000000..451ab21c --- /dev/null +++ b/src/app/(static)/events/page.tsx @@ -0,0 +1,96 @@ +import StaticCard from "~/components/static-card"; + +//? Calabar - Event 45 +import CalabarEvent451 from "~/assets/images/events/calabar/event-45/img_1368.jpg"; +import CalabarEvent452 from "~/assets/images/events/calabar/event-45/img_1375.jpg"; +import CalabarEvent453 from "~/assets/images/events/calabar/event-45/img_1378.jpg"; +import CalabarEvent454 from "~/assets/images/events/calabar/event-45/img_1387.jpg"; +import CalabarEvent455 from "~/assets/images/events/calabar/event-45/img_1394.jpg"; +import CalabarEvent456 from "~/assets/images/events/calabar/event-45/img_1398.jpg"; +import CalabarEvent457 from "~/assets/images/events/calabar/event-45/img_1399.jpg"; +import CalabarEvent458 from "~/assets/images/events/calabar/event-45/img_1408.jpg"; +import CalabarEvent459 from "~/assets/images/events/calabar/event-45/img_1423.jpg"; +import CalabarEvent4510 from "~/assets/images/events/calabar/event-45/img_1449.jpg"; +import CalabarEvent4511 from "~/assets/images/events/calabar/event-45/img_1476.jpg"; + +//? Calabar - Hotel 45 +import CalabarHotel451 from "~/assets/images/events/calabar/hotel-45/DSCF3397JPG.jpg"; +import CalabarHotel452 from "~/assets/images/events/calabar/hotel-45/DSCF3398JPG.jpg"; +import CalabarHotel453 from "~/assets/images/events/calabar/hotel-45/DSCF3399JPG.jpg"; +import CalabarHotel454 from "~/assets/images/events/calabar/hotel-45/DSCF3400JPG.jpg"; +import CalabarHotel455 from "~/assets/images/events/calabar/hotel-45/IMG_1534.jpg"; +import CalabarHotel456 from "~/assets/images/events/calabar/hotel-45/IMG_1537.jpg"; +import CalabarHotel457 from "~/assets/images/events/calabar/hotel-45/IMG_1545.jpg"; +import CalabarHotel458 from "~/assets/images/events/calabar/hotel-45/conference3JPG.jpg"; + +//? Ikom - Event Hall +import IkomEventHall1 from "~/assets/images/events/ikom/event-hall/img_9547.jpg"; +import IkomEventHall2 from "~/assets/images/events/ikom/event-hall/img_9551.jpg"; +import IkomEventHall3 from "~/assets/images/events/ikom/event-hall/img_9561.jpg"; +import IkomEventHall4 from "~/assets/images/events/ikom/event-hall/img_9847.jpg"; +import IkomEventHall5 from "~/assets/images/events/ikom/event-hall/img_9854.jpg"; + +const lodges = [ + { + images: [ + CalabarEvent451, + CalabarEvent452, + CalabarEvent453, + CalabarEvent454, + CalabarEvent455, + CalabarEvent456, + CalabarEvent457, + CalabarEvent458, + CalabarEvent459, + CalabarEvent4510, + CalabarEvent4511, + ], + link: "/booking?type=events&city=calabar", + name: "Event 45", + location: "Calabar", + }, + { + images: [ + CalabarHotel451, + CalabarHotel452, + CalabarHotel453, + CalabarHotel454, + CalabarHotel455, + CalabarHotel456, + CalabarHotel457, + CalabarHotel458, + ], + link: "/booking?type=events&city=calabar", + name: "Hotel 45", + location: "Calabar", + }, + { + images: [IkomEventHall1, IkomEventHall2, IkomEventHall3, IkomEventHall4, IkomEventHall5], + link: "/booking?type=events&city=ikom", + name: "Event Hall", + location: "Ikom", + }, +]; + +export default function Events() { + return ( +
+
+

Events

+

+ 45Group stands as your premier gateway to a world of unforgettable experiences, + meticulously hosting an array of captivating events across a diverse range of stunning + locations, from pristine beaches to majestic mountain retreats. Immerse yourself in + carefully curated, unique moments that not only captivate your senses but also linger in + your mind long after the experience has ended, weaving a tapestry of vivid memories that + will resonate throughout your lifetime and inspire your future adventures. +

+
+
+ {lodges.map((lodge, index) => ( + + ))} +
+
+ ); +} diff --git a/src/app/(static)/layout.tsx b/src/app/(static)/layout.tsx new file mode 100644 index 00000000..1ba231ad --- /dev/null +++ b/src/app/(static)/layout.tsx @@ -0,0 +1,65 @@ +import { ReactNode } from "react"; +import Link from "next/link"; +import { FaSquareXTwitter, FaInstagram, FaFacebook } from "react-icons/fa6"; +import Logo from "~/components/logo"; +import NavbarMenu from "~/modules/static-layout/navbar-menu"; +import Navbar from "~/modules/static-layout/navbar"; +import Script from "next/script"; + +type Props = { + children: ReactNode; +}; + +const links = [ + { + href: "/lodges", + text: "Lodges", + }, + { + href: "/events", + text: "Events", + }, + { + href: "/cuisine", + text: "Cuisine", + }, + { + href: "/about", + text: "About", + }, +]; + +export default function Layout({ children }: Props) { + return ( + <> +
+
+
+ + + + +
+ +
+
{children}
+
+ + + +
+ + + + + + + + + +
+
+
+ + ); +} diff --git a/src/app/(static)/lodges/page.tsx b/src/app/(static)/lodges/page.tsx new file mode 100644 index 00000000..012c96bc --- /dev/null +++ b/src/app/(static)/lodges/page.tsx @@ -0,0 +1,102 @@ +import StaticCard from "~/components/static-card"; + +//? Abuja - Guest 45 +import AbujaGuest45Lodge1 from "~/assets/images/lodges/abuja/guest-45/DSC7856.jpg"; +import AbujaGuest45Lodge2 from "~/assets/images/lodges/abuja/guest-45/DSC7877.jpg"; +import AbujaGuest45Lodge3 from "~/assets/images/lodges/abuja/guest-45/DSC7898.jpg"; +import AbujaGuest45Lodge4 from "~/assets/images/lodges/abuja/guest-45/DSC7942.jpg"; +import AbujaGuest45Lodge5 from "~/assets/images/lodges/abuja/guest-45/DSC7976.jpg"; +import AbujaGuest45Lodge6 from "~/assets/images/lodges/abuja/guest-45/DSC8017.jpg"; +import AbujaGuest45Lodge7 from "~/assets/images/lodges/abuja/guest-45/DSC8090.jpg"; + +//? Calabar - Hotel 45 +import CalabarHotel45Lodge1 from "~/assets/images/lodges/calabar/hotel-45/bathpent.jpg"; +import CalabarHotel45Lodge2 from "~/assets/images/lodges/calabar/hotel-45/img_0468.jpg"; +import CalabarHotel45Lodge3 from "~/assets/images/lodges/calabar/hotel-45/img_0473.jpg"; +import CalabarHotel45Lodge4 from "~/assets/images/lodges/calabar/hotel-45/img_0604.jpg"; +import CalabarHotel45Lodge5 from "~/assets/images/lodges/calabar/hotel-45/img_0663.jpg"; +import CalabarHotel45Lodge6 from "~/assets/images/lodges/calabar/hotel-45/img_0720.jpg"; +import CalabarHotel45Lodge7 from "~/assets/images/lodges/calabar/hotel-45/img_0798.jpg"; +import CalabarHotel45Lodge8 from "~/assets/images/lodges/calabar/hotel-45/img_0804.jpg"; +import CalabarHotel45Lodge9 from "~/assets/images/lodges/calabar/hotel-45/img_0894.jpg"; +import CalabarHotel45Lodge10 from "~/assets/images/lodges/calabar/hotel-45/img_1002.jpg"; + +//? Ikom - Hotel 45 +import IkomHotel45Lodge1 from "~/assets/images/lodges/ikom/hotel-45/img_0343.jpg"; +import IkomHotel45Lodge2 from "~/assets/images/lodges/ikom/hotel-45/img_9096.jpg"; +import IkomHotel45Lodge3 from "~/assets/images/lodges/ikom/hotel-45/img_9366.jpg"; +import IkomHotel45Lodge4 from "~/assets/images/lodges/ikom/hotel-45/img_9507.jpg"; +import IkomHotel45Lodge5 from "~/assets/images/lodges/ikom/hotel-45/img_9582.jpg"; +import IkomHotel45Lodge6 from "~/assets/images/lodges/ikom/hotel-45/img_9730.jpg"; +import IkomHotel45Lodge7 from "~/assets/images/lodges/ikom/hotel-45/img_9742.jpg"; +import IkomHotel45Lodge8 from "~/assets/images/lodges/ikom/hotel-45/img_9819.jpg"; + +const lodges = [ + { + images: [ + AbujaGuest45Lodge1, + AbujaGuest45Lodge2, + AbujaGuest45Lodge3, + AbujaGuest45Lodge4, + AbujaGuest45Lodge5, + AbujaGuest45Lodge6, + AbujaGuest45Lodge7, + ], + link: "/booking?type=rooms&city=abuja", + name: "Guest 45", + location: "Abuja", + }, + { + images: [ + CalabarHotel45Lodge1, + CalabarHotel45Lodge2, + CalabarHotel45Lodge3, + CalabarHotel45Lodge4, + CalabarHotel45Lodge5, + CalabarHotel45Lodge6, + CalabarHotel45Lodge7, + CalabarHotel45Lodge8, + CalabarHotel45Lodge9, + CalabarHotel45Lodge10, + ], + link: "/booking?type=rooms&city=calabar", + name: "Hotel 45", + location: "Calabar", + }, + { + images: [ + IkomHotel45Lodge5, + IkomHotel45Lodge1, + IkomHotel45Lodge2, + IkomHotel45Lodge3, + IkomHotel45Lodge4, + IkomHotel45Lodge6, + IkomHotel45Lodge7, + IkomHotel45Lodge8, + ], + link: "/booking?type=rooms&city=ikom", + name: "Hotel 45", + location: "Ikom", + }, +]; + +export default function Lodges() { + return ( +
+
+

Lodges

+

+ Embark on your next unforgettable adventure at Lodges Location, where breathtaking + landscapes and thrilling experiences await to inspire your wanderlust, create lasting + memories, and offer you the perfect backdrop for exploration and discovery in the great + outdoors. +

+
+
+ {lodges.map((lodge, index) => ( + + ))} +
+
+ ); +} diff --git a/src/app/_actions/util.ts b/src/app/_actions/util.ts new file mode 100644 index 00000000..53737865 --- /dev/null +++ b/src/app/_actions/util.ts @@ -0,0 +1,51 @@ +"use server"; + +import { cookies, headers } from "next/headers"; +import { COOKIE_MAX_AGE } from "~/utils/constants"; + +type CookieOptions = { + maxAge?: number; + httpOnly?: boolean; + sameSite?: "strict" | "lax" | "none"; + secure?: boolean; +}; + +export async function setCookie( + key: string, + value: string, + options: CookieOptions = {} +): Promise { + cookies().set(key, value, { + maxAge: COOKIE_MAX_AGE, + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + ...options, + }); +} + +export async function getCookie(key: string): Promise { + return cookies().get(key)?.value; +} + +export async function deleteCookie(key: string): Promise { + cookies().delete(key); +} + +export async function getAllCookies(): Promise<{ [key: string]: string }> { + const cookieStore = cookies(); + return Object.fromEntries(cookieStore.getAll().map((cookie) => [cookie.name, cookie.value])); +} + +export async function getHeader(key: string): Promise { + return headers().get(key); +} + +export async function getAllHeaders(): Promise<{ [key: string]: string }> { + const headersList = headers(); + const headersObject: { [key: string]: string } = {}; + headersList.forEach((value, key) => { + headersObject[key] = value; + }); + return headersObject; +} diff --git a/src/app/api/admin/facilities/[id]/route.ts b/src/app/api/admin/facilities/[id]/route.ts new file mode 100644 index 00000000..b7bf7cce --- /dev/null +++ b/src/app/api/admin/facilities/[id]/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "~/db"; +import { facilitiesTable } from "~/db/schemas/facilities"; +import catchAsync from "~/utils/catch-async"; +import { appError } from "~/utils/helpers"; + +export const DELETE = catchAsync(async (_: NextRequest, context: { params: { id: string } }) => { + const facilityId = context.params.id; + + const [facility] = await db + .delete(facilitiesTable) + .where(eq(facilitiesTable.id, facilityId)) + .returning(); + + if (!facility) + return appError({ + status: 404, + error: "Facility not found", + }); + + return NextResponse.json({ message: "Facility deleted successfully" }); +}); diff --git a/src/app/api/admin/facilities/route.ts b/src/app/api/admin/facilities/route.ts new file mode 100644 index 00000000..39bcff2d --- /dev/null +++ b/src/app/api/admin/facilities/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { validateSchema } from "~/utils/helpers"; +import { facilitiesTable } from "~/db/schemas/facilities"; + +export const POST = catchAsync(async (req: NextRequest) => { + const body = await req.json(); + + const { name, description } = await validateSchema<{ name: string; description: string }>({ + object: { + name: Yup.string().required("`name` is required"), + description: Yup.string().optional(), + }, + data: body, + }); + + const [newFacility] = await db + .insert(facilitiesTable) + .values({ + name, + description, + }) + .returning(); + + return NextResponse.json(newFacility); +}); + +export const GET = catchAsync(async () => { + const facilities = await db.select().from(facilitiesTable); + + return NextResponse.json(facilities); +}); diff --git a/src/app/api/admin/groups/[id]/route.ts b/src/app/api/admin/groups/[id]/route.ts new file mode 100644 index 00000000..ef4ab322 --- /dev/null +++ b/src/app/api/admin/groups/[id]/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { appError } from "~/utils/helpers"; +import { groupsTable } from "~/db/schemas/groups"; + +export const DELETE = catchAsync(async (_: NextRequest, context: { params: { id: string } }) => { + const groupId = context.params.id; + + const [group] = await db.delete(groupsTable).where(eq(groupsTable.id, groupId)).returning(); + + if (!group) + return appError({ + status: 404, + error: "Group not found", + }); + + return NextResponse.json({ message: "Group deleted successfully" }); +}); diff --git a/src/app/api/admin/groups/route.ts b/src/app/api/admin/groups/route.ts new file mode 100644 index 00000000..d4f99404 --- /dev/null +++ b/src/app/api/admin/groups/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { asc } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { validateSchema } from "~/utils/helpers"; +import { groupsTable } from "~/db/schemas/groups"; + +export const POST = catchAsync(async (req: NextRequest) => { + const body = await req.json(); + + const { name } = await validateSchema<{ + name: string; + }>({ + object: { + name: Yup.string().required("`name` is required"), + }, + data: body, + }); + + const [newGroup] = await db + .insert(groupsTable) + .values({ + name, + }) + .returning(); + + return NextResponse.json(newGroup); +}); + +export const GET = catchAsync(async () => { + const rules = await db.select().from(groupsTable).orderBy(asc(groupsTable.created_at)); + + return NextResponse.json(rules); +}); diff --git a/src/app/api/admin/locations/[id]/media/route.ts b/src/app/api/admin/locations/[id]/media/route.ts new file mode 100644 index 00000000..d547e130 --- /dev/null +++ b/src/app/api/admin/locations/[id]/media/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, eq, inArray } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { appError, validateSchema } from "~/utils/helpers"; +import { locationsTable } from "~/db/schemas/locations"; +import YupValidation from "~/utils/yup-validations"; +import UploadService from "~/services/upload"; +import { mediasTable } from "~/db/schemas/media"; + +export const POST = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const locationId = context.params.id; + const body = await req.formData(); + + const { medias, name, state, city } = await validateSchema<{ + name: string; + state: string; + city: string; + medias: File[]; + }>({ + object: { + name: Yup.string().required("`name` is required"), + state: Yup.string().required("`state` is required"), + city: Yup.string().required("`city` is required"), + medias: YupValidation.validateFiles().required("`medias` is required"), + }, + isFormData: true, + data: body, + }); + + const [location] = await db + .select() + .from(locationsTable) + .where(eq(locationsTable.id, locationId)); + + if (!location) + return appError({ + status: 404, + error: "Location not found", + }); + + const mediaUrls = await UploadService.uploadMultiple( + medias, + `locations/${state}/${city}/${name}` + ); + + await Promise.all( + mediaUrls.map(({ url, size, type }) => + db.insert(mediasTable).values({ + url, + size, + mimeType: type, + location_id: location.id, + }) + ) + ); + + return NextResponse.json({ message: "Media Uploaded" }); +}); + +export const DELETE = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const locationId = context.params.id; + const body = await req.json(); + + const { media_ids } = await validateSchema<{ + media_ids: string[]; + }>({ + object: { + media_ids: Yup.array().of(Yup.string().uuid("Must be a valid UUID")).required(), + }, + data: body, + }); + + const [location] = await db + .select() + .from(locationsTable) + .where(eq(locationsTable.id, locationId)); + + if (!location) + return appError({ + status: 404, + error: "Location not found", + }); + + await db + .delete(mediasTable) + .where(and(eq(mediasTable.location_id, locationId), inArray(mediasTable.id, media_ids))) + .execute(); + + return NextResponse.json({ message: "Media deleted" }); +}); diff --git a/src/app/api/admin/locations/[id]/route.ts b/src/app/api/admin/locations/[id]/route.ts new file mode 100644 index 00000000..e96bb06e --- /dev/null +++ b/src/app/api/admin/locations/[id]/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import { locationsTable } from "~/db/schemas/locations"; +import catchAsync from "~/utils/catch-async"; +import { appError, validateSchema } from "~/utils/helpers"; +import { mediasTable } from "~/db/schemas/media"; + +export const DELETE = catchAsync(async (_: NextRequest, context: { params: { id: string } }) => { + const locationId = context.params.id; + + const [location] = await db + .delete(locationsTable) + .where(eq(locationsTable.id, locationId)) + .returning(); + + if (!location) + return appError({ + status: 404, + error: "Location not found", + }); + + return NextResponse.json({ message: "Location deleted successfully" }); +}); + +export const GET = catchAsync(async (_: NextRequest, context: { params: { id: string } }) => { + const locationId = context.params.id; + + const location = await db.query.locationsTable.findFirst({ + where: (location, { eq }) => eq(location.id, locationId), + with: { + resources: true, + medias: true, + }, + }); + + if (!location) { + return appError({ + status: 404, + error: "Location not found", + }); + } + + return NextResponse.json(location); +}); + +export const PATCH = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const locationId = context.params.id; + const body = await req.json(); + + const validatedData = await validateSchema<{ + name: string; + state: string; + city: string; + description: string; + }>({ + object: { + name: Yup.string().optional(), + state: Yup.string().optional(), + city: Yup.string().optional(), + description: Yup.string().optional(), + }, + data: body, + }); + + const [updatedLocation] = await db + .update(locationsTable) + .set({ + ...validatedData, + }) + .where(eq(locationsTable.id, locationId)) + .returning(); + + if (!updatedLocation) + return appError({ + status: 404, + error: "Location not found", + }); + + return NextResponse.json(updatedLocation); +}); diff --git a/src/app/api/admin/locations/route.ts b/src/app/api/admin/locations/route.ts new file mode 100644 index 00000000..2e702e73 --- /dev/null +++ b/src/app/api/admin/locations/route.ts @@ -0,0 +1,112 @@ +import { asc, ilike, or, count as sqlCount } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import * as Yup from "yup"; +import { db } from "~/db"; +import { locationsTable } from "~/db/schemas/locations"; +import { mediasTable } from "~/db/schemas/media"; +import UploadService from "~/services/upload"; +import catchAsync from "~/utils/catch-async"; +import { validateSchema } from "~/utils/helpers"; +import YupValidation from "~/utils/yup-validations"; + +export const POST = catchAsync(async (req: NextRequest) => { + const body = await req.formData(); + + const { images, ...validatedData } = await validateSchema<{ + name: string; + state: string; + city: string; + description: string; + images: File[]; + }>({ + object: { + name: Yup.string().required("`name` is required"), + state: Yup.string().required("`state` is required"), + city: Yup.string().required("`city` is required"), + description: Yup.string().optional(), + images: YupValidation.validateFiles().required("`images` is required"), + }, + isFormData: true, + data: body, + }); + + const imageUrls = await UploadService.uploadMultiple( + images, + `locations/${validatedData.state}/${validatedData.city}/${validatedData.name}` + ); + + const [newLocation] = await db + .insert(locationsTable) + .values({ + ...validatedData, + }) + .returning(); + + await Promise.all( + imageUrls.map(({ url, size, type }) => + db.insert(mediasTable).values({ + url, + size, + mimeType: type, + location_id: newLocation.id, + }) + ) + ); + + return NextResponse.json(newLocation); +}); + +export const GET = catchAsync(async (req: NextRequest) => { + const searchParams = req.nextUrl.searchParams; + + const { + limit, + offset, + q = "", + } = await validateSchema<{ + limit: number; + offset: number; + q: string; + }>({ + object: { + limit: Yup.number() + .optional() + .transform((value) => (isNaN(value) ? undefined : value)) + .integer("Limit must be an integer"), + offset: Yup.number() + .optional() + .transform((value) => (isNaN(value) ? undefined : value)) + .integer("Offset must be an integer"), + q: Yup.string().optional(), + }, + data: { + limit: searchParams.get("limit") !== null ? parseInt(searchParams.get("limit")!) : undefined, + offset: + searchParams.get("offset") !== null ? parseInt(searchParams.get("offset")!) : undefined, + q: searchParams.get("q") || undefined, + }, + }); + + const baseQuery = db + .select() + .from(locationsTable) + .where(or(ilike(locationsTable.name, `%${q}%`))); + + if (limit === undefined || offset === undefined) { + const locations = await baseQuery.orderBy(asc(locationsTable.created_at)); + return NextResponse.json(locations); + } + + const [data, [count]] = await Promise.all([ + baseQuery.limit(limit).offset(offset), + db + .select({ count: sqlCount() }) + .from(locationsTable) + .where(ilike(locationsTable.name, `%${q}%`)), + ]); + + return NextResponse.json({ + data, + ...count, + }); +}); diff --git a/src/app/api/admin/resources/[id]/facility/route.ts b/src/app/api/admin/resources/[id]/facility/route.ts new file mode 100644 index 00000000..ed345ecf --- /dev/null +++ b/src/app/api/admin/resources/[id]/facility/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, eq, inArray } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import { resourceFacilitiesTable, resourcesTable } from "~/db/schemas/resources"; +import catchAsync from "~/utils/catch-async"; +import { appError, validateSchema } from "~/utils/helpers"; + +type Schema = { + facility_ids: string[]; +}; + +const schema = { + facility_ids: Yup.array() + .of(Yup.string().uuid("Must be a valid UUID")) + .required("`facility_ids` is required"), +}; + +export const POST = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const resourceId = context.params.id; + const body = await req.json(); + + const { facility_ids } = await validateSchema({ + object: schema, + data: body, + }); + + const [resource] = await db + .select() + .from(resourcesTable) + .where(eq(resourcesTable.id, resourceId)); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + await db.insert(resourceFacilitiesTable).values( + facility_ids.map((facility_id: string) => ({ + resource_id: resource.id, + facility_id, + })) + ); + + return NextResponse.json({ message: "Facilities successfully associated with the resource." }); +}); + +export const DELETE = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const resourceId = context.params.id; + const body = await req.json(); + + const { facility_ids } = await validateSchema({ + object: schema, + data: body, + }); + + const [resource] = await db + .select() + .from(resourcesTable) + .where(eq(resourcesTable.id, resourceId)); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + await db + .delete(resourceFacilitiesTable) + .where( + and( + eq(resourceFacilitiesTable.resource_id, resourceId), + inArray(resourceFacilitiesTable.facility_id, facility_ids) + ) + ) + .execute(); + + return NextResponse.json({ message: "Facilities successfully removed from the resource." }); +}); diff --git a/src/app/api/admin/resources/[id]/group/route.ts b/src/app/api/admin/resources/[id]/group/route.ts new file mode 100644 index 00000000..3098f9b3 --- /dev/null +++ b/src/app/api/admin/resources/[id]/group/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, eq, inArray } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import { resourceGroupsTable, resourcesTable } from "~/db/schemas/resources"; +import catchAsync from "~/utils/catch-async"; +import { appError, validateSchema } from "~/utils/helpers"; + +export const POST = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const resourceId = context.params.id; + const body = await req.json(); + + const { group_ids } = await validateSchema<{ + group_ids: { id: string; num: number }[]; + }>({ + object: { + group_ids: Yup.array() + .of( + Yup.object({ + id: Yup.string().uuid("Must be a valid UUID"), + num: Yup.number().required("`num` is required"), + }) + ) + .required("`group_ids` is required"), + }, + data: body, + }); + + const [resource] = await db + .select() + .from(resourcesTable) + .where(eq(resourcesTable.id, resourceId)); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + await db.insert(resourceGroupsTable).values( + group_ids.map(({ id: group_id, num }) => ({ + resource_id: resource.id, + group_id, + num, + })) + ); + + return NextResponse.json({ message: "Groups successfully associated with the resource." }); +}); + +export const DELETE = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const resourceId = context.params.id; + const body = await req.json(); + + const { group_ids } = await validateSchema<{ + group_ids: string[]; + }>({ + object: { + group_ids: Yup.array() + .of(Yup.string().uuid("Must be a valid UUID")) + .required("`group_ids` is required"), + }, + data: body, + }); + + const [resource] = await db + .select() + .from(resourcesTable) + .where(eq(resourcesTable.id, resourceId)); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + await db + .delete(resourceGroupsTable) + .where( + and( + eq(resourceGroupsTable.resource_id, resourceId), + inArray(resourceGroupsTable.group_id, group_ids) + ) + ) + .execute(); + + return NextResponse.json({ message: "Groups successfully removed from the resource." }); +}); diff --git a/src/app/api/admin/resources/[id]/media/route.ts b/src/app/api/admin/resources/[id]/media/route.ts new file mode 100644 index 00000000..8e29f642 --- /dev/null +++ b/src/app/api/admin/resources/[id]/media/route.ts @@ -0,0 +1,85 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import * as Yup from "yup"; +import { db } from "~/db"; +import { mediasTable } from "~/db/schemas/media"; +import { resourcesTable } from "~/db/schemas/resources"; +import UploadService from "~/services/upload"; +import catchAsync from "~/utils/catch-async"; +import { appError, validateSchema } from "~/utils/helpers"; +import YupValidation from "~/utils/yup-validations"; + +export const POST = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const resourceId = context.params.id; + const body = await req.formData(); + + const { medias } = await validateSchema<{ + medias: File[]; + }>({ + object: { + medias: YupValidation.validateFiles().required("`medias` is required"), + }, + isFormData: true, + data: body, + }); + + const [resource] = await db + .select() + .from(resourcesTable) + .where(eq(resourcesTable.id, resourceId)); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + const mediaUrls = await UploadService.uploadMultiple(medias, `resources/media`); + + await Promise.all( + mediaUrls.map(({ url, size, type }) => + db.insert(mediasTable).values({ + url, + size, + mimeType: type, + resource_id: resource.id, + }) + ) + ); + + return NextResponse.json({ message: "Media Uploaded" }); +}); + +export const DELETE = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const resourceId = context.params.id; + const body = await req.json(); + + const { media_ids } = await validateSchema<{ + media_ids: string[]; + }>({ + object: { + media_ids: Yup.array() + .of(Yup.string().uuid("Must be a valid UUID")) + .required("`media_ids` is required"), + }, + data: body, + }); + + const [resource] = await db + .select() + .from(resourcesTable) + .where(eq(resourcesTable.id, resourceId)); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + await db + .delete(mediasTable) + .where(and(eq(mediasTable.resource_id, resourceId), inArray(mediasTable.id, media_ids))) + .execute(); + + return NextResponse.json({ message: "Media deleted" }); +}); diff --git a/src/app/api/admin/resources/[id]/route.ts b/src/app/api/admin/resources/[id]/route.ts new file mode 100644 index 00000000..3d33aab0 --- /dev/null +++ b/src/app/api/admin/resources/[id]/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import { Resource, ResourceSchedule, resourcesTable } from "~/db/schemas/resources"; +import catchAsync from "~/utils/catch-async"; +import { appError, validateSchema } from "~/utils/helpers"; +import UploadService from "~/services/upload"; +import YupValidation from "~/utils/yup-validations"; +import { SCHEDULE_TYPE } from "~/utils/constants"; +import { schedule } from "~/utils/yup-schemas/resource"; + +const resourceType = ["lodge", "event", "dining"]; +const resourceStatus = ["draft", "published"]; + +const resourceSchema = { + location_id: Yup.string().uuid("Location id not valid").optional(), + name: Yup.string().optional(), + type: Yup.string() + .oneOf(resourceType, `Type must be one of: ${resourceType.join(", ")}`) + .optional(), + schedule_type: Yup.string() + .lowercase() + .oneOf(SCHEDULE_TYPE, `schedule_type must be one of: ${SCHEDULE_TYPE.join(", ")}`) + .optional(), + description: Yup.string().optional(), + thumbnail: YupValidation.validateSingleFile({ + required: false, + }), + status: Yup.string() + .lowercase() + .oneOf(resourceStatus, `status must be one of: ${resourceStatus.join(", ")}`) + .optional(), + schedules: Yup.array() + .of(schedule) + .min(1, "`schedules` must have at least one schedule") + .when("schedule_type", { + is: (value: string) => value !== "24/7", + otherwise: (schema) => schema.notRequired(), + }) + .optional(), +}; + +export const DELETE = catchAsync(async (_: NextRequest, context: { params: { id: string } }) => { + const resourceId = context.params.id; + + const [resource] = await db + .delete(resourcesTable) + .where(eq(resourcesTable.id, resourceId)) + .returning(); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + return NextResponse.json({ message: "Resource deleted successfully" }); +}); + +export const GET = catchAsync(async (_: NextRequest, context: { params: { id: string } }) => { + const resourceId = context.params.id; + + const resource = await db.query.resourcesTable.findFirst({ + where: (resource, { eq }) => eq(resource.id, resourceId), + with: { + facilities: true, + medias: true, + location: true, + groups: true, + rules: { + with: { + rule: true, + }, + }, + schedules: true, + }, + }); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + return NextResponse.json(resource); +}); + +export const PATCH = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const resourceId = context.params.id; + const body = await req.formData(); + + const { thumbnail, ...validatedData } = await validateSchema< + Partial<{ + name: string; + type: Resource["type"]; + location_id: string; + thumbnail: File; + schedule_type: Resource["schedule_type"]; + publish: boolean; + description: string; + schedules: ResourceSchedule[]; + }> + >({ + object: resourceSchema, + isFormData: true, + data: body, + }); + + const [resource] = await db + .select() + .from(resourcesTable) + .where(eq(resourcesTable.id, resourceId)); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + let thumbnailData; + + if (thumbnail) { + thumbnailData = await UploadService.uploadSingle(thumbnail, "resources/thumbnails"); + } + + const [updatedResource] = await db + .update(resourcesTable) + .set({ ...validatedData, thumbnail: thumbnailData?.url }) + .where(eq(resourcesTable.id, resourceId)) + .returning(); + + return NextResponse.json(updatedResource); +}); diff --git a/src/app/api/admin/resources/[id]/rule/route.ts b/src/app/api/admin/resources/[id]/rule/route.ts new file mode 100644 index 00000000..51659468 --- /dev/null +++ b/src/app/api/admin/resources/[id]/rule/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, eq, inArray } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import { resourceRulesTable, resourcesTable } from "~/db/schemas/resources"; +import catchAsync from "~/utils/catch-async"; +import { appError, validateSchema } from "~/utils/helpers"; + +type Schema = { + rule_ids: string[]; +}; + +const schema = { + rule_ids: Yup.array() + .of(Yup.string().uuid("Must be a valid UUID")) + .required("`rule_ids` is required"), +}; + +export const POST = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const resourceId = context.params.id; + const body = await req.json(); + + const { rule_ids } = await validateSchema({ + object: schema, + data: body, + }); + + const [resource] = await db + .select() + .from(resourcesTable) + .where(eq(resourcesTable.id, resourceId)); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + await db.insert(resourceRulesTable).values( + rule_ids.map((rule_id: string) => ({ + resource_id: resource.id, + rule_id, + })) + ); + + return NextResponse.json({ message: "Rules successfully associated with the resource." }); +}); + +export const DELETE = catchAsync(async (req: NextRequest, context: { params: { id: string } }) => { + const resourceId = context.params.id; + const body = await req.json(); + + const { rule_ids } = await validateSchema({ + object: schema, + data: body, + }); + + const [resource] = await db + .select() + .from(resourcesTable) + .where(eq(resourcesTable.id, resourceId)); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + await db + .delete(resourceRulesTable) + .where( + and( + eq(resourceRulesTable.resource_id, resourceId), + inArray(resourceRulesTable.rule_id, rule_ids) + ) + ) + .execute(); + + return NextResponse.json({ message: "Rules successfully removed from the resource." }); +}); diff --git a/src/app/api/admin/resources/[id]/schedules/route.ts b/src/app/api/admin/resources/[id]/schedules/route.ts new file mode 100644 index 00000000..3763afbc --- /dev/null +++ b/src/app/api/admin/resources/[id]/schedules/route.ts @@ -0,0 +1,14 @@ +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { db } from "~/db"; +import { resourceSchedulesTable } from "~/db/schemas"; +import catchAsync from "~/utils/catch-async"; + +export const GET = catchAsync(async (_: NextRequest, { params }: { params: { id: string } }) => { + const schedules = await db.query.resourceSchedulesTable.findMany({ + where: eq(resourceSchedulesTable.resource_id, params.id), + orderBy: resourceSchedulesTable.day_of_week, + }); + + return NextResponse.json(schedules); +}); diff --git a/src/app/api/admin/resources/route.ts b/src/app/api/admin/resources/route.ts new file mode 100644 index 00000000..47434e8a --- /dev/null +++ b/src/app/api/admin/resources/route.ts @@ -0,0 +1,131 @@ +import { NextRequest, NextResponse } from "next/server"; +import { asc, eq, ilike, or, count as sqlCount } from "drizzle-orm"; +import slugify from "slugify"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { appError, validateSchema } from "~/utils/helpers"; +import { + Resource, + ResourceSchedule, + resourceSchedulesTable, + resourcesTable, +} from "~/db/schemas/resources"; +import UploadService from "~/services/upload"; +import { resourceSchema } from "~/utils/yup-schemas/resource"; + +type Schema = { + name: string; + type: Resource["type"]; + location_id: string; + thumbnail: File; + schedule_type: Resource["schedule_type"]; + publish: boolean; + description: string; + schedules: Pick[]; +}; + +export const POST = catchAsync(async (req: NextRequest) => { + const body = await req.formData(); + + const { publish, thumbnail, schedules, ...validatedData } = await validateSchema({ + object: resourceSchema, + isFormData: true, + data: body, + }); + + const [newResource] = await db + .insert(resourcesTable) + .values({ + ...validatedData, + handle: slugify(validatedData.name, { + lower: true, + strict: true, + }), + status: publish ? "published" : "draft", + thumbnail: "/pending-upload/" + Date.now() + "-" + thumbnail.name, + }) + .returning(); + + if (!newResource) + return appError({ + status: 422, + error: "Error processing request", + }); + + const thumbnailData = await UploadService.uploadSingle(thumbnail, "resources/thumbnails"); + + const [[updatedResource]] = await Promise.all([ + db + .update(resourcesTable) + .set({ thumbnail: thumbnailData.url }) + .where(eq(resourcesTable.id, newResource.id)) + .returning(), + + validatedData.schedule_type !== "24/7" && schedules + ? db.insert(resourceSchedulesTable).values( + schedules.map((schedule) => ({ + ...schedule, + resource_id: newResource.id, + })) + ) + : Promise.resolve(), + ]); + + return NextResponse.json(updatedResource); +}); + +export const GET = catchAsync(async (req: NextRequest) => { + const searchParams = req.nextUrl.searchParams; + + const { + limit, + offset, + q = "", + } = await validateSchema<{ + q: string; + limit: number; + offset: number; + }>({ + object: { + limit: Yup.number() + .optional() + .transform((value) => (isNaN(value) ? undefined : value)) + .integer("Limit must be an integer"), + offset: Yup.number() + .optional() + .transform((value) => (isNaN(value) ? undefined : value)) + .integer("Offset must be an integer"), + q: Yup.string().optional(), + }, + data: { + limit: searchParams.get("limit") !== null ? parseInt(searchParams.get("limit")!) : undefined, + offset: + searchParams.get("offset") !== null ? parseInt(searchParams.get("offset")!) : undefined, + q: searchParams.get("q") || undefined, + }, + }); + + const baseQuery = db + .select() + .from(resourcesTable) + .where(or(ilike(resourcesTable.name, `%${q}%`), ilike(resourcesTable.description, `%${q}%`))); + + if (limit === undefined || offset === undefined) { + const locations = await baseQuery.orderBy(asc(resourcesTable.created_at)); + return NextResponse.json(locations); + } + + const [data, [count]] = await Promise.all([ + baseQuery.limit(limit).offset(offset), + db + .select({ count: sqlCount() }) + .from(resourcesTable) + .where(ilike(resourcesTable.name, `%${q}%`)), + ]); + + return NextResponse.json({ + data, + ...count, + }); +}); diff --git a/src/app/api/admin/rules/[id]/route.ts b/src/app/api/admin/rules/[id]/route.ts new file mode 100644 index 00000000..39a1f73b --- /dev/null +++ b/src/app/api/admin/rules/[id]/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "~/db"; +import { rulesTable } from "~/db/schemas/rules"; +import catchAsync from "~/utils/catch-async"; +import { appError } from "~/utils/helpers"; + +export const DELETE = catchAsync(async (_: NextRequest, context: { params: { id: string } }) => { + const ruleId = context.params.id; + + const [rule] = await db.delete(rulesTable).where(eq(rulesTable.id, ruleId)).returning(); + + if (!rule) + return appError({ + status: 404, + error: "Rule not found", + }); + + return NextResponse.json({ message: "Rule deleted successfully" }); +}); diff --git a/src/app/api/admin/rules/route.ts b/src/app/api/admin/rules/route.ts new file mode 100644 index 00000000..3d0f2b43 --- /dev/null +++ b/src/app/api/admin/rules/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { validateSchema } from "~/utils/helpers"; +import { Rule, rulesTable } from "~/db/schemas/rules"; + +export const POST = catchAsync(async (req: NextRequest) => { + const body = await req.json(); + + const { name, description, category } = await validateSchema< + Pick + >({ + object: { + name: Yup.string().required("`name` is required"), + description: Yup.string().optional(), + category: Yup.string() + .oneOf(["house_rules", "cancellations"]) + .required("`category` is required"), + }, + data: body, + }); + + const [newRule] = await db + .insert(rulesTable) + .values({ + name, + category, + description, + }) + .returning(); + + return NextResponse.json(newRule); +}); + +export const GET = catchAsync(async () => { + const rules = await db.select().from(rulesTable); + + return NextResponse.json(rules); +}); diff --git a/src/app/api/assets/[...name]/route.ts b/src/app/api/assets/[...name]/route.ts new file mode 100644 index 00000000..e95d717d --- /dev/null +++ b/src/app/api/assets/[...name]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest } from "next/server"; +import catchAsync from "~/utils/catch-async"; +import { getFileStreamFromS3 } from "~/utils/s3"; + +export const GET = catchAsync( + async (request: NextRequest, { params: { name } }: { params: { name: string[] } }) => { + const versionId = request.nextUrl.searchParams.get("versionId") || undefined; + + const filename = name.join("/"); + + const stream = await getFileStreamFromS3(filename, versionId); + + if (!stream) { + return new Response("File not found", { status: 404 }); + } + + const webStream = new ReadableStream({ + start(controller) { + stream.on("data", (chunk) => { + controller.enqueue(chunk); + }); + stream.on("end", () => { + controller.close(); + }); + stream.on("error", (err) => { + controller.error(err); + }); + }, + cancel() { + stream.destroy(); + }, + }); + + return new Response(webStream, { + status: 200, + headers: { + "Content-Type": "image/*", + "Cache-Control": "public, max-age=3600", + }, + }); + } +); diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..6d28cb48 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,56 @@ +import NextAuth from "next-auth"; +import { and, eq } from "drizzle-orm"; +import CredentialsProvider from "next-auth/providers/credentials"; +import { db } from "~/db"; +import { usersTable } from "~/db/schemas/users"; +import { COOKIE_MAX_AGE, SESSION_KEY } from "~/utils/constants"; + +const handler = NextAuth({ + providers: [ + CredentialsProvider({ + id: "credentials", + name: "Credentials", + credentials: { + email: { label: "Email", type: "email" }, + }, + async authorize(credentials) { + if (!credentials?.email) return null; + + const [user] = await db + .select() + .from(usersTable) + .where(and(eq(usersTable.email, credentials.email), eq(usersTable.is_verified, true))); + + if (!user) return null; + + return { + id: user.id, + }; + }, + }), + ], + session: { + strategy: "jwt", + maxAge: COOKIE_MAX_AGE, + generateSessionToken() { + return SESSION_KEY; + }, + }, + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id; + } + return token; + }, + async session({ session, token }) { + session.user = { + id: token.id, + }; + + return session; + }, + }, +}); + +export { handler as GET, handler as POST }; diff --git a/src/app/api/auth/jwt/create/route.ts b/src/app/api/auth/jwt/create/route.ts new file mode 100644 index 00000000..b6b2f9aa --- /dev/null +++ b/src/app/api/auth/jwt/create/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, eq } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { usersTable } from "~/db/schemas/users"; +import { appError, signJwt } from "~/utils/helpers"; +import { COOKIE_MAX_AGE, JWT_KEY } from "~/utils/constants"; + +export const POST = catchAsync(async (req: NextRequest) => { + const body = await req.json(); + + const schema = Yup.object({ + email: Yup.string().email("Invalid email address").required("`email` is required"), + }); + const { email } = await schema.validate({ ...body }, { abortEarly: false, stripUnknown: true }); + + const [user] = await db + .select() + .from(usersTable) + .where(and(eq(usersTable.email, email), eq(usersTable.is_verified, true))); + + if (!user) { + return appError({ + status: 400, + error: "Verified user not found", + }); + } + + const access = signJwt.access(user.id); + const refresh = signJwt.refresh(user.id); + + const response = NextResponse.json({ + access, + refresh, + }); + + response.cookies.set(JWT_KEY, refresh, { + maxAge: COOKIE_MAX_AGE, + }); + + return response; +}); diff --git a/src/app/api/auth/jwt/refresh/route.ts b/src/app/api/auth/jwt/refresh/route.ts new file mode 100644 index 00000000..2a2f0482 --- /dev/null +++ b/src/app/api/auth/jwt/refresh/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { verify as JwtVerify } from "jsonwebtoken"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { usersTable } from "~/db/schemas/users"; +import { appError, signJwt } from "~/utils/helpers"; +import { blacklistedTokenTable } from "~/db/schemas/blacklisted-token"; +import { COOKIE_MAX_AGE, JWT_KEY } from "~/utils/constants"; + +export const POST = catchAsync(async (req: NextRequest) => { + const body = await req.json(); + + const schema = Yup.object({ + refresh: Yup.string().required("`refresh` is required"), + }); + const { refresh } = await schema.validate({ ...body }, { abortEarly: false, stripUnknown: true }); + + const { user_id } = JwtVerify(refresh, process.env.JWT_SECRET as string) as { + user_id: string; + iat: number; + exp: number; + }; + + const [blacklistedToken] = await db + .select() + .from(blacklistedTokenTable) + .where(eq(blacklistedTokenTable.token, refresh)); + + if (blacklistedToken) { + return appError({ + status: 401, + error: "Token has been revoked", + }); + } + + const [user] = await db.select().from(usersTable).where(eq(usersTable.id, user_id)); + + if (!user) { + return appError({ + status: 404, + error: "User not found", + }); + } + + await db.insert(blacklistedTokenTable).values({ + token: refresh, + blacklisted_at: new Date(), + }); + + const access = signJwt.access(user.id); + const newRefresh = signJwt.refresh(user.id); + + const response = NextResponse.json({ + access, + refresh: newRefresh, + }); + + response.cookies.set(JWT_KEY, newRefresh, { + maxAge: COOKIE_MAX_AGE, + }); + + return response; +}); diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 00000000..b2885be9 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "~/db"; +import { blacklistedTokenTable } from "~/db/schemas/blacklisted-token"; +import { SESSION_KEY } from "~/utils/constants"; +import catchAsync from "~/utils/catch-async"; + +export const POST = catchAsync(async (req: NextRequest) => { + const token = req.cookies.get(SESSION_KEY)?.value as string; + + await db.insert(blacklistedTokenTable).values({ + token, + blacklisted_at: new Date(), + }); + + const res = NextResponse.json({ + message: "User logged out successfully", + }); + + res.cookies.delete(SESSION_KEY); + + return res; +}); diff --git a/src/app/api/auth/otp/request/route.ts b/src/app/api/auth/otp/request/route.ts new file mode 100644 index 00000000..6ac2f681 --- /dev/null +++ b/src/app/api/auth/otp/request/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { otpsTable } from "~/db/schemas/otps"; +import { hashValue, validateSchema } from "~/utils/helpers"; +import { sendEmail } from "~/config/resend"; +import RequestOtpTemplate from "~/emails/request-otp"; + +function generateOTP(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); +} + +export const POST = catchAsync(async (req: NextRequest) => { + const body = await req.json(); + + const { email } = await validateSchema<{ email: string }>({ + object: { + email: Yup.string().email("Invalid email address").required("Email is required"), + }, + data: body, + }); + + const otp = generateOTP(); + const hashedOTP = hashValue(otp); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // OTP expires in 5 minutes + + await db.insert(otpsTable).values({ + email, + hashed_otp: hashedOTP, + expires_at: expiresAt, + }); + + await sendEmail({ + to: email, + subject: `Request OTP: ${otp}`, + react: RequestOtpTemplate({ + code: otp, + previewText: "Your one-time password (OTP) for 45Group account verification is ready...", + }), + }); + + return NextResponse.json({ + message: "OTP sent to email", + }); +}); diff --git a/src/app/api/auth/otp/verify/route.ts b/src/app/api/auth/otp/verify/route.ts new file mode 100644 index 00000000..3cf01be0 --- /dev/null +++ b/src/app/api/auth/otp/verify/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, eq, gt } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { otpsTable } from "~/db/schemas/otps"; +import { appError, hashValue, validateSchema } from "~/utils/helpers"; + +export const POST = catchAsync(async (req: NextRequest) => { + const body = await req.json(); + + const { email, otp } = await validateSchema<{ email: string; otp: string }>({ + object: { + email: Yup.string().email("Invalid email address").required("Email is required"), + otp: Yup.string() + .length(6, "OTP must be exactly 6 digits") + .matches(/^\d{6}$/, "OTP must be 6 digits") + .required("OTP is required"), + }, + data: body, + }); + + const hashedOTP = hashValue(otp); + const currentTime = new Date(); + + const [validOTP] = await db + .select() + .from(otpsTable) + .where( + and( + eq(otpsTable.email, email), + eq(otpsTable.hashed_otp, hashedOTP), + gt(otpsTable.expires_at, currentTime) + ) + ); + + if (!validOTP) { + return appError({ + status: 400, + error: "The OTP does not exist or has expired.", + }); + } + + // const [existingUser] = await db + // .select({ + // last_login_at: usersTable.last_login_at, + // }) + // .from(usersTable) + // .where(eq(usersTable.email, email)); + + await db.delete(otpsTable).where(eq(otpsTable.id, validOTP.id)); + // db + // .update(usersTable) + // .set({ last_login_at: currentTime, is_verified: true }) + // .where(eq(usersTable.email, email)) + // .returning(), + + // if (!existingUser?.last_login_at) { + // await sendEmail({ + // to: email, + // subject: "Welcome to 45Group", + // react: WelcomeTemplate({ + // previewText: + // "We're excited to have you join our platform where you can discover and book the finest lodges, events, and...", + // }), + // }); + // } + + return NextResponse.json({ + message: "Email successfully verified", + }); +}); diff --git a/src/app/api/auth/session/create/route.ts b/src/app/api/auth/session/create/route.ts new file mode 100644 index 00000000..c382f824 --- /dev/null +++ b/src/app/api/auth/session/create/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { encode } from "next-auth/jwt"; +import { and, eq } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { usersTable } from "~/db/schemas/users"; +import { appError, validateSchema } from "~/utils/helpers"; +import { COOKIE_MAX_AGE, SESSION_KEY } from "~/utils/constants"; + +export const POST = catchAsync(async (req: NextRequest) => { + const body = await req.json(); + + const { email } = await validateSchema<{ email: string }>({ + object: { + email: Yup.string().email("Invalid email address").required("`email` is required"), + }, + data: body, + }); + + const [user] = await db + .select() + .from(usersTable) + .where(and(eq(usersTable.email, email), eq(usersTable.is_verified, true))); + + if (!user) { + return appError({ + status: 400, + error: "Verified user not found", + }); + } + + const token = await encode({ + token: { + id: user.id, + }, + secret: process.env.NEXTAUTH_SECRET!, + maxAge: COOKIE_MAX_AGE, + }); + + const res = NextResponse.json({ + message: "Session created", + }); + + res.cookies.set(SESSION_KEY, token, { + httpOnly: true, + sameSite: "strict", // Prevents cross-site access + path: "/", + secure: process.env.NODE_ENV === "production", + maxAge: COOKIE_MAX_AGE, + }); + + return res; +}); diff --git a/src/app/api/auth/set-email/route.ts b/src/app/api/auth/set-email/route.ts new file mode 100644 index 00000000..a0966636 --- /dev/null +++ b/src/app/api/auth/set-email/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import { usersTable } from "~/db/schemas/users"; +import catchAsync from "~/utils/catch-async"; +import { appError, validateSchema } from "~/utils/helpers"; +import { sendEmail } from "~/config/resend"; +import EmailChangeConfirmation from "~/emails/email-change-confirmation"; +import { HEADER_DATA_KEY } from "~/utils/constants"; + +export const POST = catchAsync(async (req: NextRequest) => { + const userId = req.headers.get(HEADER_DATA_KEY) as string; + const body = await req.json(); + + const { new_email } = await validateSchema<{ new_email: string }>({ + object: { + new_email: Yup.string().email("Invalid new email address").required("New email is required"), + }, + data: body, + }); + + const [existingUser] = await db.select().from(usersTable).where(eq(usersTable.email, new_email)); + + if (existingUser) { + return appError({ + status: 400, + error: "The new email is already in use.", + }); + } + + const [updatedUser] = await db + .update(usersTable) + .set({ email: new_email }) + .where(eq(usersTable.id, userId)) + .returning(); + + if (!updatedUser) { + return appError({ + status: 404, + error: "No user found.", + }); + } + + await sendEmail({ + to: new_email, + subject: "Your email address has been updated successfully!", + react: EmailChangeConfirmation({ + previewText: + "Your email address was updated successfully. Contact us if you didn't make this change.", + }), + }); + + return NextResponse.json(updatedUser); +}); diff --git a/src/app/api/auth/signin/route.ts b/src/app/api/auth/signin/route.ts new file mode 100644 index 00000000..872c81bc --- /dev/null +++ b/src/app/api/auth/signin/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { usersTable } from "~/db/schemas/users"; +import { validateSchema } from "~/utils/helpers"; +import WelcomeTemplate from "~/emails/welcome"; +import { sendEmail } from "~/config/resend"; + +export const POST = catchAsync(async (req: NextRequest) => { + const body = await req.json(); + + const { email } = await validateSchema<{ email: string }>({ + object: { + email: Yup.string().email("Invalid email address").required("Email is required"), + }, + data: body, + }); + + const [existingUser] = await db.select().from(usersTable).where(eq(usersTable.email, email)); + + if (!existingUser) { + await db.insert(usersTable).values({ email }); + } + + const currentTime = new Date(); + + await db + .update(usersTable) + .set({ last_login_at: currentTime, is_verified: !existingUser?.is_verified ? true : undefined }) + .where(eq(usersTable.email, email)); + + if (!existingUser?.last_login_at) { + await sendEmail({ + to: email, + subject: "Welcome to 45Group", + react: WelcomeTemplate({ + previewText: + "We're excited to have you join our platform where you can discover and book the finest lodges, events, and...", + }), + }); + } + + return NextResponse.json({ + message: "User signed in successfully", + }); +}); diff --git a/src/app/api/resources/[slug]/route.ts b/src/app/api/resources/[slug]/route.ts new file mode 100644 index 00000000..966620b5 --- /dev/null +++ b/src/app/api/resources/[slug]/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { appError } from "~/utils/helpers"; + +export const GET = catchAsync(async (_: NextRequest, context: { params: { slug: string } }) => { + const resourceSlug = context.params.slug; + + const resource = await db.query.resourcesTable.findFirst({ + where: (resource, { eq }) => eq(resource.handle, resourceSlug), + with: { + facilities: true, + medias: true, + location: true, + groups: true, + rules: { + with: { + rule: true, + }, + }, + schedules: true, + }, + }); + + if (!resource) + return appError({ + status: 404, + error: "Resource not found", + }); + + return NextResponse.json(resource); +}); diff --git a/src/app/api/resources/route.ts b/src/app/api/resources/route.ts new file mode 100644 index 00000000..6a2b042f --- /dev/null +++ b/src/app/api/resources/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, or, ilike, asc, count as sqlCount } from "drizzle-orm"; +import * as Yup from "yup"; +import { db } from "~/db"; +import catchAsync from "~/utils/catch-async"; +import { validateSchema } from "~/utils/helpers"; +import { resourcesTable } from "~/db/schemas"; + +export const dynamic = "force-dynamic"; + +export const GET = catchAsync(async (req: NextRequest) => { + const searchParams = req.nextUrl.searchParams; + + const { + limit, + offset, + q = "", + } = await validateSchema<{ + limit: number; + offset: number; + q: string; + }>({ + object: { + limit: Yup.number().integer("Limit must be an integer").optional(), + offset: Yup.number().integer("Offset must be an integer").optional(), + q: Yup.string().optional(), + }, + data: { + limit: searchParams.get("limit") ? parseInt(searchParams.get("limit")!) : undefined, + offset: searchParams.get("offset") ? parseInt(searchParams.get("offset")!) : undefined, + q: searchParams.get("q") || undefined, + }, + }); + + const queryCondition = or( + ilike(resourcesTable.name, `%${q}%`), + ilike(resourcesTable.description, `%${q}%`) + ); + + const baseQuery = db.select().from(resourcesTable).where(queryCondition); + + if (limit === undefined || offset === undefined) { + const locations = await baseQuery.orderBy(asc(resourcesTable.created_at)); + return NextResponse.json(locations); + } + + const [data, totalCount] = await Promise.all([ + baseQuery.limit(limit).offset(offset).orderBy(asc(resourcesTable.created_at)), + db.select({ count: sqlCount() }).from(resourcesTable).where(queryCondition), + ]); + + return NextResponse.json({ + data, + total: totalCount[0]?.count ?? 0, + }); +}); diff --git a/src/app/api/users/me/route.ts b/src/app/api/users/me/route.ts new file mode 100644 index 00000000..bd526df4 --- /dev/null +++ b/src/app/api/users/me/route.ts @@ -0,0 +1,78 @@ +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { isValidPhoneNumber } from "libphonenumber-js"; +import * as Yup from "yup"; +import { db } from "~/db"; +import { usersTable } from "~/db/schemas/users"; +import catchAsync from "~/utils/catch-async"; +import { HEADER_DATA_KEY } from "~/utils/constants"; +import { appError, validateSchema } from "~/utils/helpers"; +import UploadService from "~/services/upload"; + +export const PATCH = catchAsync(async (req: NextRequest) => { + const middlewareData = req.headers.get(HEADER_DATA_KEY); + const { userId }: { userId: string } = middlewareData ? JSON.parse(middlewareData) : {}; + + const formData = await req.formData(); + const body = Object.fromEntries(formData); + + const { image, ...validatedData } = await validateSchema<{ + first_name: string; + last_name: string; + phone: string; + complete_profile: boolean; + image: File; + }>({ + object: { + first_name: Yup.string().trim().optional(), + last_name: Yup.string().trim().optional(), + phone: Yup.string() + .test("valid-phone", "Please enter a valid phone number", (value) => { + return !value || isValidPhoneNumber(value); + }) + .optional(), + complete_profile: Yup.boolean().optional(), + image: Yup.mixed().optional(), + }, + data: body, + }); + + let imageUrl = null; + if (image) { + imageUrl = await UploadService.uploadSingle(image, "profiles"); + } + + const [updatedUser] = await db + .update(usersTable) + .set({ + ...validatedData, + image: imageUrl?.url ?? undefined, + }) + .where(eq(usersTable.id, userId)) + .returning(); + + if (!updatedUser) { + return appError({ + status: 404, + error: "User not found", + }); + } + + return NextResponse.json(updatedUser); +}); + +export const GET = catchAsync(async (req: NextRequest) => { + const middlewareData = req.headers.get(HEADER_DATA_KEY); + const { userId }: { userId: string } = middlewareData ? JSON.parse(middlewareData) : {}; + + const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId)); + + if (!user) { + return appError({ + status: 404, + error: "User not found", + }); + } + + return NextResponse.json(user); +}); diff --git a/src/app/api/utils/decode/route.ts b/src/app/api/utils/decode/route.ts new file mode 100644 index 00000000..71353a12 --- /dev/null +++ b/src/app/api/utils/decode/route.ts @@ -0,0 +1,30 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { decode } from "next-auth/jwt"; +import { eq } from "drizzle-orm"; +import { db } from "~/db"; +import { blacklistedTokenTable } from "~/db/schemas/blacklisted-token"; +import catchAsync from "~/utils/catch-async"; +import { appError } from "~/utils/helpers"; + +export const POST = catchAsync(async (req: NextRequest) => { + const { session } = await req.json(); + + const [blacklistedToken] = await db + .select() + .from(blacklistedTokenTable) + .where(eq(blacklistedTokenTable.token, session)); + + if (blacklistedToken) { + return appError({ + status: 400, + error: "Token is blacklisted", + }); + } + + const data = await decode({ + secret: process.env.NEXTAUTH_SECRET!, + token: session, + }); + + return NextResponse.json({ user_id: data?.id }); +}); diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index f693a20e..a8719f45 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -1,10 +1,14 @@ "use client"; -// import Image from "next/image"; +import { useEffect } from "react"; +import Image from "next/image"; +import { captureException } from "@sentry/nextjs"; import { ThemeProvider } from "@mui/material/styles"; import theme from "./theme"; import Button from "~/components/button"; -// import ErrorIllustrtion from "~/assets/illustrations/error.png"; +import ErrorIllustrtion from "~/assets/illustrations/500-error.png"; +import { cn } from "~/utils/helpers"; +import { merriweather } from "~/utils/fonts"; export default function GlobalError({ error, @@ -13,19 +17,26 @@ export default function GlobalError({ error: Error & { digest?: string }; reset: () => void; }) { - console.error(error); + useEffect(() => { + captureException(error); + }, [error]); return ( - + -
-
- {/* Error occured illustration */} +
+
+ Error occured illustration
-

An Error Occured!

- {process.env.NODE_ENV === "development" &&

Error: {error.message}

} +

An Error Occured!

+ {/* {process.env.NODE_ENV === "development" &&

Error: {error.message}

} */}
diff --git a/src/app/globals.css b/src/app/globals.css index 1e53e94e..6a7beb5f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -29,6 +29,22 @@ --secondary-800: #1f5c2f; --secondary-900: #1b4c29; --secondary-950: #0a2913; + + --info: #52525b; + --info-50: #fafafa; + --info-100: #f4f4f5; + --info-200: #e4e4e7; + --info-300: #d4d4d8; + --info-400: #a1a1aa; + --info-500: #71717a; + --info-600: #52525b; + --info-700: #3f3f46; + --info-800: #27272a; + --info-900: #18181b; + } + + * { + @apply font-merriweather; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 89d8b963..698e2966 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,18 +1,31 @@ import type { Metadata } from "next"; -import { Inter } from "next/font/google"; +import Script from "next/script"; import { ThemeProvider } from "@mui/material"; import { AppRouterCacheProvider } from "@mui/material-nextjs/v13-appRouter"; +import { Analytics } from "@vercel/analytics/next"; import "./globals.css"; import theme from "./theme"; import { cn } from "~/utils/helpers"; import TanstackQueryProvider from "~/providers/tanstack-query"; import Toast from "~/components/toast"; - -const inter = Inter({ subsets: ["latin"] }); +import { dancing_script, merriweather } from "~/utils/fonts"; +import AppProgressBar from "~/components/app-progress-bar"; +import LogoutModal from "~/components/logout-modal"; +import { ConfirmationPromptProvider } from "~/providers/confirmation-prompt"; export const metadata: Metadata = { title: "45Group", - description: "Generated by create next app", + description: "where the heart is", + keywords: [ + "Hotel 45", + "Hotel45", + "Event 45", + "Club 45", + "Lounge 45", + "Bar 45", + "Bar 90", + "45 Group", + ], }; export default function RootLayout({ @@ -22,15 +35,32 @@ export default function RootLayout({ }>) { return ( - + - - {children} + + + + {children} + + + +