A modern travel agency platform with an admin dashboard and public site. Generate AI-powered trip itineraries based on country, travel style, interests, group type, and budget
π Table of Contents
- π€ Introduction
- βοΈ Tech Stack
- π Features
- π€Έ Quick Start
- πΈοΈ Snippets (Code to Copy)
- π Assets
- π More
A modern travel agency platform with an admin dashboard and public site. Generate AI-powered trip itineraries based on country, travel style, interests, group type, and budget β and book trips with ease.
If you're getting started and need assistance or face any bugs, join our active Discord community with over 50k+ members. It's a place where people help each other out.
- Syncfusion
- React Router v7 (framework mode)
- Appwrite
- Tailwind CSS
- Vite
- React 19
π AI-powered trip itinerary generator
π Trip booking functionality on the public website
π Admin dashboard with trip and user management
π User growth metrics and trip analytics
π Interactive charts and trip statistics table
π Detailed trip overview
π Responsive UI with a modern design
π Secure user authentication and data management
π Modular code architecture with reusable components
and many more, built for scalability and a smooth user experience.
Follow these steps to set up the project locally on your machine.
Prerequisites
Make sure you have the following installed on your machine:
Cloning the Repository
git clone https://github.com/rr3s1/ai-admin-dashboard.git
cd ai-admin-dashboardInstallation
Install the project dependencies using npm:
npm installSet Up Environment Variables
Create a new file named .env in the root of your project and add the following content:
VITE_SYNCFUSION_LICENSE_KEY=
VITE_APPWRITE_PROJECT_ID=
VITE_APPWRITE_API_ENDPOINT=
VITE_APPWRITE_API_KEY=
VITE_APPWRITE_DATABASE_ID=
VITE_APPWRITE_USERS_COLLECTION_ID=
VITE_APPWRITE_ITINERARY_COLLECTION_ID=
STRIPE_SECRET_KEY=
GEMINI_API_KEY=
UNSPLASH_ACCESS_KEY="
VITE_BASE_URL="http://localhost:5173"Running the Project
npm run devOpen http://localhost:5173 in your browser to view the project.
app.css
@import url("https://fonts.googleapis.com/css2?family=Figtree:ital,wght@0,300..900;1,300..900&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
@import "../node_modules/@syncfusion/ej2-base/styles/material.css";
@import "../node_modules/@syncfusion/ej2-react-buttons/styles/material.css";
@import "../node_modules/@syncfusion/ej2-react-grids/styles/material.css";
@import "../node_modules/@syncfusion/ej2-react-navigations/styles/material.css";
@import "../node_modules/@syncfusion/ej2-react-splitbuttons/styles/material.css";
@import "../node_modules/@syncfusion/ej2-react-dropdowns/styles/material.css";
@import "tailwindcss";
@theme {
--font-figtree: "Figtree", sans-serif;
--font-inter: "Inter", sans-serif;
--color-dark-100: #1f1f36;
--color-dark-200: #141627;
--color-dark-300: #101828;
--color-dark-400: #2e2c48;
--color-gray-100: #7f7e83;
--color-gray-200: #eaecf0;
--color-gray-500: #667085;
--color-gray-700: #344054;
--color-primary-50: #e9f3fb;
--color-primary-100: #256ff1;
--color-primary-500: #175cd3;
--color-light-100: #ecf2ef;
--color-light-200: #f9fbfc;
--color-light-300: #f2f4f7;
--color-light-400: #ebeeed;
--color-light-500: #e3f1ff;
--color-success-50: #ecfdf3;
--color-success-500: #12b76a;
--color-success-700: #027a48;
--color-pink-50: #f7edf6;
--color-pink-500: #c11574;
--color-navy-50: #f0f9ff;
--color-navy-500: #026aa2;
--color-red-500: #b93815;
--color-red-50: #fff4ed;
--color-red-100: #ff543d;
--background-image-auth: url("/assets/images/auth-img.webp");
--background-image-hero: url("/assets/images/hero-img.png");
--background-image-card-1: url("/assets/images/card-img-1.png");
--background-image-card-2: url("/assets/images/card-img-2.png");
--background-image-card-3: url("/assets/images/card-img-3.png");
--background-image-card-4: url("/assets/images/card-img-4.png");
--background-image-card-5: url("/assets/images/card-img-5.png");
--background-image-card-6: url("/assets/images/card-img-6.png");
--background-image-linear100: linear-gradient(
105deg,
rgba(207, 241, 255, 0.8) 14.17%,
rgba(255, 255, 255, 0) 54.71%
);
--background-image-linear200: linear-gradient(
39deg,
rgba(3, 3, 3, 0.54) -3.66%,
rgba(6, 6, 6, 0) 45.57%
);
--shadow-100:
0px 1px 3px 0px rgba(16, 24, 40, 0.1),
0px 1px 2px 0px rgba(16, 24, 40, 0.06);
--shadow-200:
0px 12px 16px -4px rgba(16, 24, 40, 0.1),
0px 4px 20px -2px rgba(16, 24, 40, 0.2);
--shadow-300: 0px 2px 30px 0px rgba(0, 0, 0, 0.05);
--shadow-400: 0px 2px 6px 0px rgba(13, 10, 44, 0.08);
--shadow-500: 0px 12px 16px -4px rgba(16, 24, 40, 0.1);
--radius-20: 20px;
}
@layer components {
.error {
@apply text-red-500 text-base font-medium text-center;
}
.all-users {
@apply w-full min-h-screen flex flex-col gap-10;
}
.stats-card {
@apply p-6 flex flex-col gap-6 bg-white shadow-400 rounded-20 text-dark-100;
.content {
@apply flex flex-row md:flex-col-reverse xl:flex-row xl:items-center gap-3 justify-between;
}
}
.sign-in-card {
@apply flex bg-white flex-col border border-light-100 md:max-w-[510px] rounded-[20px] py-10 px-6 w-full;
header {
@apply flex items-center gap-1.5 justify-center;
}
article {
@apply mt-9 mb-[30px] flex flex-col gap-3;
}
}
.info-pill {
@apply flex items-center gap-1.5;
img {
@apply size-5;
}
figcaption {
@apply text-sm md:text-lg font-normal truncate text-gray-100;
}
}
.trip {
@apply flex flex-col gap-10 pb-20;
section {
@apply flex flex-col gap-5 mt-2.5;
}
}
.auth {
@apply w-full h-screen flex bg-auth bg-cover bg-no-repeat;
}
.payment-success {
@apply flex flex-col gap-10 pb-20 items-center justify-center h-screen;
section {
@apply flex flex-col gap-5 justify-between w-full items-center;
article {
@apply flex flex-col gap-3.5 w-full items-center justify-center md:max-w-[488px];
h1 {
@apply text-xl md:text-3xl font-semibold text-dark-100;
}
p {
@apply text-gray-100 text-sm font-normal md:text-lg text-center;
}
}
}
}
.trip-form {
@apply flex flex-col gap-6 py-6 bg-white border border-light-200 rounded-xl shadow-100;
div {
@apply w-full flex flex-col gap-2.5 px-6 relative;
}
label {
@apply text-sm font-normal text-gray-100;
}
}
.travel-hero {
@apply bg-hero bg-origin-content bg-cover;
div {
@apply flex flex-col bg-linear100 bg-cover;
section {
@apply py-48 justify-center items-start flex flex-col gap-6;
article {
@apply flex flex-col w-full md:max-w-[520px] gap-3.5;
p {
@apply text-lg font-normal text-dark-400;
}
}
}
}
}
.travel-featured {
@apply flex flex-col lg:flex-row gap-[30px] h-2/3 lg:h-1/2;
}
.travel-detail {
@apply flex flex-col gap-10 pb-20;
.travel-div {
@apply flex flex-col lg:flex-row gap-10;
}
.back-link {
@apply flex items-center justify-center gap-2.5 py-3 px-[30px] border-gray-200 rounded-lg shadow-500 bg-white h-[50px] w-[240px];
img {
@apply size-[17px];
}
span {
@apply text-base font-semibold text-dark-100;
}
}
.container {
@apply flex flex-col gap-9 mt-2.5;
header {
@apply flex flex-col gap-6 overflow-hidden;
div {
@apply flex items-center gap-5;
}
}
.gallery {
@apply grid grid-cols-1 md:grid-cols-3 md:grid-rows-2 gap-7 mt-1;
}
}
.visit {
@apply flex flex-col gap-5;
div {
@apply flex flex-col gap-4;
h3 {
@apply text-base md:text-xl text-dark-400 font-semibold;
}
ul {
@apply flex flex-col gap-3;
li {
@apply flex justify-between gap-7 text-sm md:text-lg font-normal text-dark-400 !list-disc;
}
}
}
}
.itinerary {
@apply flex flex-col gap-9;
li {
@apply flex flex-col gap-4;
h3 {
@apply text-base md:text-xl font-semibold text-dark-400;
}
ul {
@apply flex flex-col sm:gap-3 gap-7;
li {
@apply flex max-sm:flex-col flex-row justify-between sm:gap-7 gap-3 text-sm md:text-lg font-normal text-dark-400 !list-disc;
span {
@apply w-[90px];
}
}
}
}
}
.title {
@apply flex justify-between gap-5;
article {
@apply flex flex-col gap-4;
h3 {
@apply text-xl md:text-3xl text-dark-100 font-semibold;
}
p {
@apply text-base md:text-2xl text-gray-100 font-normal;
}
}
h2 {
@apply text-sm md:text-xl font-normal text-dark-100;
}
}
}
.trip-card {
@apply shadow-300 bg-white rounded-[20px] flex-col w-full relative;
img {
@apply w-full h-[160px] rounded-t-xl object-cover aspect-video;
}
article {
@apply flex flex-col gap-3 mt-4 pl-[18px] pr-3.5;
h2 {
@apply text-sm md:text-lg font-semibold text-dark-100 line-clamp-2;
}
figure {
@apply flex items-center gap-2;
figCaption {
@apply text-xs md:text-sm font-normal text-gray-100;
}
}
}
}
.link-logo {
@apply flex items-center gap-1.5 py-10 border-b border-light-100;
h1 {
@apply text-base md:text-2xl font-bold text-dark-100;
}
}
.nav-footer {
@apply flex items-center gap-2.5 pb-8;
img {
@apply size-10 rounded-full aspect-square;
}
article {
@apply flex flex-col gap-[2px] max-w-[115px];
h2 {
@apply text-sm md:text-base font-semibold text-dark-200 truncate;
}
p {
@apply text-gray-100 text-xs md:text-sm font-normal truncate;
}
}
}
.mobile-sidebar {
@apply lg:hidden flex flex-col gap-5;
header {
@apply flex justify-between items-center border-b border-light-100;
h1 {
@apply text-base md:text-2xl font-bold text-dark-100;
}
a {
@apply flex items-center gap-1.5 py-10;
}
}
}
.root-nav {
@apply flex justify-between gap-4 items-center;
a {
@apply flex items-center gap-1.5 py-10;
h1 {
@apply text-base md:text-2xl font-bold text-dark-100;
}
}
aside {
@apply flex gap-4 items-center;
img {
@apply size-10 rounded-full aspect-square;
}
}
}
.footer-container {
@apply flex justify-between items-center h-full gap-5;
a {
@apply flex items-center gap-1.5 py-10;
h1 {
@apply text-base md:text-2xl font-bold text-dark-100;
}
}
div {
@apply flex items-center gap-2 sm:gap-5;
a {
@apply text-sm md:text-base font-normal text-gray-100;
}
}
}
.header {
@apply flex flex-col gap-5 md:flex-row justify-between w-full;
article {
@apply flex flex-col gap-3.5 w-full;
}
}
.nav-items {
@apply flex flex-col px-6 h-full;
.container {
@apply flex flex-col justify-between h-full;
nav {
@apply flex flex-col gap-3.5 pt-9;
}
}
}
.admin-layout {
@apply flex flex-col lg:flex-row h-screen w-full;
.children {
@apply w-full h-full bg-light-200 pt-12 lg:pt-10;
}
}
.user-trip {
@apply pb-20 flex flex-col lg:flex-row gap-5 justify-between;
}
.status-column {
@apply flex justify-center items-center gap-1 w-[65px] py-[2px] rounded-2xl mix-blend-multiply;
}
.dashboard {
@apply flex flex-col gap-10 w-full pb-20;
.container {
@apply flex flex-col gap-5 mt-2.5;
h1 {
@apply text-xl font-semibold text-dark-100;
}
}
}
.featured-card {
@apply flex flex-col justify-between gap-3.5 p-[30px] min-h-[230px] h-full;
}
.featured {
@apply flex flex-col md:flex-row gap-[30px];
article {
@apply flex flex-col gap-[30px] w-full;
}
}
}
@layer utilities {
.wrapper {
@apply w-full max-w-7xl mx-auto px-4 lg:px-8;
}
.wrapper-md {
@apply w-full max-w-3xl px-4 lg:px-8 mx-auto;
}
.flex-center {
@apply flex justify-center items-center;
}
.flex-between {
@apply flex justify-between items-center;
}
.p-72-bold {
@apply text-5xl md:text-7xl font-bold;
}
.p-40-semibold {
@apply text-3xl md:text-[40px] md:leading-[44px] font-semibold;
}
.p-30-bold {
@apply text-2xl md:text-3xl font-bold;
}
.p-28-bold {
@apply text-[20px] md:text-[28px] leading-[16px] md:leading-[20px] font-bold;
}
.p-28-semibold {
@apply text-[20px] text-2xl md:text-[28px] leading-[16px] md:leading-[20px] font-semibold;
}
.p-24-semibold {
@apply text-lg md:text-2xl font-semibold;
}
.p-20-semibold {
@apply text-base md:text-[20xp] md:leading-7 font-semibold;
}
.p-18-bold {
@apply text-[14px] md:text-[18px] leading-[14px] md:leading-[16px] font-bold;
}
.p-18-semibold {
@apply text-[14px] md:text-[18px] leading-[14px] md:leading-[16px] font-semibold;
}
.p-18-regular {
@apply text-[14px] md:text-[18px] leading-[14px] md:leading-[16px] font-normal;
}
.p-16-semibold {
@apply text-sm md:text-base font-semibold;
}
.button-class {
@apply !bg-primary-100 !px-4 !rounded-lg !flex !items-center !justify-center !gap-1.5 !shadow-none;
}
.button-class-secondary {
@apply !bg-white !px-4 !rounded-lg !flex !items-center !justify-center !gap-1.5 !shadow-sm;
}
.form-label {
@apply text-sm font-normal text-gray-100;
}
.form-input {
@apply p-3.5 border border-light-400 rounded-xl text-base text-dark-300 font-normal;
}
.comboBox-popup {
@apply absolute z-10 top-24 bg-white border border-gray-200 rounded-xl shadow-200 h-[250px] w-full md:max-w-[660px] overflow-hidden;
}
.tripCard-pill {
@apply bg-white py-1 px-2.5 w-fit rounded-[20px] absolute top-2.5 right-4 text-dark-100 text-sm font-semibold;
}
.price-pill {
@apply bg-white py-0.5 px-2.5 w-fit rounded-[20px] top-2.5 right-4 text-dark-100 text-sm font-semibold;
}
.trip-grid {
@apply grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-7;
}
.nav-item {
@apply flex items-center text-xs md:text-lg font-normal cursor-pointer gap-2.5 py-[18px] px-3.5 rounded-lg text-dark-200 hover:bg-primary-100 hover:text-white;
}
.combo-box {
@apply !p-3.5 !border w-full !border-light-400 !rounded-xl !text-base !text-dark-300 !font-normal;
}
}
html,
body {
font-family: "Figtree", sans-serif;
background-color: #f9fbfc;
scroll-behavior: smooth;
}
.glassmorphism {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
/* ======== css overrides for syncfusion ========= */
.e-grid,
.e-table {
border-color: #eef9ff !important;
}
.e-control {
border-radius: 12px !important;
}
.e-grid .e-gridheader {
border-color: #eef9ff !important;
border-radius: 12px 12px 0 0 !important;
}
.e-grid .e-row:nth-child(odd) {
background-color: #f9fbfc;
}
.e-grid .e-rowcell {
padding-top: 18px !important;
padding-bottom: 18px !important;
}
.e-sidebar.e-left {
border-right: 1px solid #ecf2ef;
}
.e-btn,
.e-css.e-btn {
text-transform: none;
}
.e-dropdown-btn {
border-radius: 6px;
border: 1px solid #f0fef9;
background: #ffffff;
box-shadow:
0px 0px 3px 0px rgba(16, 24, 40, 0.1),
0px 0px 2px 0px rgba(16, 24, 40, 0.06);
height: 40px;
}
.e-dropdown-popup ul {
border-radius: 8px;
border: 1px solid #eaecf0;
background: #fff;
box-shadow:
0px 12px 16px -4px rgba(16, 24, 40, 0.1),
0px 4px 20px -2px rgba(16, 24, 40, 0.2);
}
.e-dropdown-popup ul .e-item {
padding-left: 36px;
padding-right: 36px;
}
/* e-input-group-icon e-ddl-icon e-search-icon */
.e-input-group-icon {
position: absolute;
top: 45px;
right: 40px;
}constants/index.ts
import type { AxisModel } from "@syncfusion/ej2-react-charts";
export const sidebarItems = [
{
id: 1,
icon: "/assets/icons/home.svg",
label: "Dashboard",
href: "/dashboard",
},
{
id: 3,
icon: "/assets/icons/users.svg",
label: "All Users",
href: "/all-users",
},
{
id: 4,
icon: "/assets/icons/itinerary.svg",
label: "AI Trips",
href: "/trips",
},
];
export const chartOneData: object[] = [
{
x: "Jan",
y1: 0.5,
y2: 1.5,
y3: 0.7,
},
{
x: "Feb",
y1: 0.8,
y2: 1.2,
y3: 0.9,
},
{
x: "Mar",
y1: 1.2,
y2: 1.8,
y3: 1.5,
},
{
x: "Apr",
y1: 1.5,
y2: 2.0,
y3: 1.8,
},
{
x: "May",
y1: 1.8,
y2: 2.5,
y3: 2.0,
},
{
x: "Jun",
y1: 2.0,
y2: 2.8,
y3: 2.5,
},
];
export const travelStyles = [
"Relaxed",
"Luxury",
"Adventure",
"Cultural",
"Nature & Outdoors",
"City Exploration",
];
export const interests = [
"Food & Culinary",
"Historical Sites",
"Hiking & Nature Walks",
"Beaches & Water Activities",
"Museums & Art",
"Nightlife & Bars",
"Photography Spots",
"Shopping",
"Local Experiences",
];
export const budgetOptions = ["Budget", "Mid-range", "Luxury", "Premium"];
export const groupTypes = ["Solo", "Couple", "Family", "Friends", "Business"];
export const footers = ["Terms & Condition", "Privacy Policy"];
export const selectItems = [
"groupType",
"travelStyle",
"interest",
"budget",
] as (keyof TripFormData)[];
export const comboBoxItems = {
groupType: groupTypes,
travelStyle: travelStyles,
interest: interests,
budget: budgetOptions,
} as Record<keyof TripFormData, string[]>;
export const userXAxis: AxisModel = { valueType: "Category", title: "Day" };
export const useryAxis: AxisModel = {
minimum: 0,
maximum: 10,
interval: 2,
title: "Count",
};
export const tripXAxis: AxisModel = {
valueType: "Category",
title: "Travel Styles",
majorGridLines: { width: 0 },
};
export const tripyAxis: AxisModel = {
minimum: 0,
maximum: 10,
interval: 2,
title: "Count",
};
export const CONFETTI_SETTINGS = {
particleCount: 200, // Number of confetti pieces
spread: 60, // Spread of the confetti burst
colors: ["#ff0", "#ff7f00", "#ff0044", "#4c94f4", "#f4f4f4"], // Confetti colors
decay: 0.95, // Gravity decay of the confetti
};
export const LEFT_CONFETTI = {
...CONFETTI_SETTINGS,
angle: 45, // Direction of the confetti burst (90 degrees is top)
origin: { x: 0, y: 1 }, // Center of the screen
};
export const RIGHT_CONFETTI = {
...CONFETTI_SETTINGS,
angle: 135,
origin: { x: 1, y: 1 },
};index.d.ts
declare interface BaseUser {
id: string;
name: string;
email: string;
dateJoined: string;
imageUrl: string;
}
declare interface UserData extends BaseUser {
itineraryCreated: number | string;
status: "user" | "admin";
}
declare type User = BaseUser;
declare interface Country {
name: string;
coordinates: [number, number];
value: string;
openStreetMap?: string;
}
declare interface DropdownItem {
name: string;
}
declare interface SelectProps {
data: Country[] | DropdownItem[];
onValueChange: (value: string) => void;
id: string;
label: string;
placeholder: string;
}
declare interface PillProps {
text: string;
bgColor?: string;
textColor?: string;
}
declare interface Activity {
time: string;
description: string;
}
declare interface DayPlan {
day: number;
location: string;
activities: Activity[];
}
declare interface Location {
city: string;
coordinates: [number, number];
openStreetMap: string;
}
declare interface Trip {
id: string;
name: string;
description: string;
estimatedPrice: string;
duration: number;
budget: string;
travelStyle: string;
interests: string;
groupType: string;
country: string;
imageUrls: string[];
itinerary: DayPlan[];
bestTimeToVisit: string[];
weatherInfo: string[];
location: Location;
payment_link: string;
}
declare interface TripCardProps {
id: string;
name: string;
location: string;
imageUrl: string;
tags: string[];
price: string;
}
declare interface StatsCard {
headerTitle: string;
total: number;
lastMonthCount: number;
currentMonthCount: number;
}
declare interface TrendResult {
trend: "increment" | "decrement" | "no change";
percentage: number;
}
declare interface DashboardStats {
totalUsers: number;
usersJoined: {
currentMonth: number;
lastMonth: number;
};
userRole: {
total: number;
currentMonth: number;
lastMonth: number;
};
totalTrips: number;
tripsCreated: {
currentMonth: number;
lastMonth: number;
};
}
declare interface CreateTripResponse {
id?: string;
}
declare interface DestinationProps {
containerClass?: string;
bigCard?: boolean;
activityCount: number;
rating: number;
bgImage: string;
title: string;
}
type GetAllTripsResponse = {
allTrips: Models.Document[];
total: number;
};
declare interface UsersItineraryCount {
imageUrl: string;
name: string;
count: number;
}
declare interface TripsInterest {
imageUrl: string;
name: string;
interest: string;
}
declare interface InfoPillProps {
text: string;
image: string;
}
declare interface TripFormData {
country: string;
travelStyle: string;
interest: string;
budget: string;
duration: number;
groupType: string;
}app/lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import dayjs from "dayjs";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const formatDate = (dateString: string): string => {
return dayjs(dateString).format("MMMM DD, YYYY");
};
export function parseMarkdownToJson(markdownText: string): unknown | null {
const regex = /```json\n([\s\S]+?)\n```/;
const match = markdownText.match(regex);
if (match && match[1]) {
try {
return JSON.parse(match[1]);
} catch (error) {
console.error("Error parsing JSON:", error);
return null;
}
}
console.error("No valid JSON found in markdown text.");
return null;
}
export function parseTripData(jsonString: string): Trip | null {
try {
const data: Trip = JSON.parse(jsonString);
return data;
} catch (error) {
console.error("Failed to parse trip data:", error);
return null;
}
}
export function getFirstWord(input: string = ""): string {
return input.trim().split(/\s+/)[0] || "";
}
export const calculateTrendPercentage = (
countOfThisMonth: number,
countOfLastMonth: number
): TrendResult => {
if (countOfLastMonth === 0) {
return countOfThisMonth === 0
? { trend: "no change", percentage: 0 }
: { trend: "increment", percentage: 100 };
}
const change = countOfThisMonth - countOfLastMonth;
const percentage = Math.abs((change / countOfLastMonth) * 100);
if (change > 0) {
return { trend: "increment", percentage };
} else if (change < 0) {
return { trend: "decrement", percentage };
} else {
return { trend: "no change", percentage: 0 };
}
};
export const formatKey = (key: keyof TripFormData) => {
return key
.replace(/([A-Z])/g, " $1")
.replace(/^./, (str) => str.toUpperCase());
};npm install syncfusion
npm install @syncfusion/ej2-base @syncfusion/ej2-react-buttons @syncfusion/ej2-react-charts @syncfusion/ej2-react-grids @syncfusion/ej2-react-dropdowns @syncfusion/ej2-react-maps @syncfusion/ej2-react-navigations @syncfusion/ej2-react-splitbuttonsdashboardStats (dummy)
const dashboardStats = {
totalUsers: 1237,
usersJoined: { currentMonth: 218, lastMonth: 176 },
totalTrips: 118,
tripsCreated: { currentMonth: 6, lastMonth: 2 },
userRole: { total: 62, currentMonth: 63, lastMonth: 78 },
};allTrips (dummy)
const allTrips = [{
id: 1,
name: "Tropical Rewind",
imageUrls: ["/assets/images/sample1.jpg"],
itinerary: [{ location: "Thailand" }],
tags: ["Adventure", "Culture"],
travelStyle: "Solo",
estimatedPrice: "$1,000",
},
{
id: 2,
name: "French Reverie",
imageUrls: ["/assets/images/sample2.jpg"],
itinerary: [{ location: "Paris" }],
tags: ["Relaxation", "Culinary"],
travelStyle: "Family",
estimatedPrice: "$2,000",
},
{
id: 3,
name: "Zen Break",
imageUrls: ["/assets/images/sample3.jpg"],
itinerary: [{ location: "Japan" }],
tags: ["Shopping", "Luxury"],
travelStyle: "Couple",
estimatedPrice: "$3,000",
},
{
id: 4,
name: "Adventure in Westeros",
imageUrls: ["/assets/images/sample4.jpg"],
itinerary: [{ location: "Croatia" }],
tags: ["Historical", "Culture"],
travelStyle: "Friends",
estimatedPrice: "$4,000",
},
];users (dummy)
const users = [
{
id: 1,
name: "John Doe",
email: "john.doe@example.com",
imageUrl: "/assets/images/david.webp",
dateJoined: formatDate("2025-01-01"),
itineraryCreated: 10,
status: "user",
},
{
id: 2,
name: "Jane Smith",
email: "jane.smith@example.com",
imageUrl: "/assets/images/david.webp",
dateJoined: formatDate("2025-01-02"),
itineraryCreated: 4,
status: "user",
},
{
id: 3,
name: "John Smith",
email: "john.smith@example.com",
imageUrl: "/assets/images/david.webp",
dateJoined: formatDate("2025-01-03"),
itineraryCreated: 8,
status: "admin",
},
];Create Trip Prompt
const prompt = `Generate a ${numberOfDays}-day travel itinerary for ${country} based on the following user information:
Budget: '${budget}'
Interests: '${interests}'
TravelStyle: '${travelStyle}'
GroupType: '${groupType}'
Return the itinerary and lowest estimated price in a clean, non-markdown JSON format with the following structure:
{
"name": "A descriptive title for the trip",
"description": "A brief description of the trip and its highlights not exceeding 100 words",
"estimatedPrice": "Lowest average price for the trip in USD, e.g.$price",
"duration": ${numberOfDays},
"budget": "${budget}",
"travelStyle": "${travelStyle}",
"country": "${country}",
"interests": ${interests},
"groupType": "${groupType}",
"bestTimeToVisit": [
'πΈ Season (from month to month): reason to visit',
'βοΈ Season (from month to month): reason to visit',
'π Season (from month to month): reason to visit',
'βοΈ Season (from month to month): reason to visit'
],
"weatherInfo": [
'βοΈ Season: temperature range in Celsius (temperature range in Fahrenheit)',
'π¦οΈ Season: temperature range in Celsius (temperature range in Fahrenheit)',
'π§οΈ Season: temperature range in Celsius (temperature range in Fahrenheit)',
'βοΈ Season: temperature range in Celsius (temperature range in Fahrenheit)'
],
"location": {
"city": "name of the city or region",
"coordinates": [latitude, longitude],
"openStreetMap": "link to open street map"
},
"itinerary": [
{
"day": 1,
"location": "City/Region Name",
"activities": [
{"time": "Morning", "description": "π° Visit the local historic castle and enjoy a scenic walk"},
{"time": "Afternoon", "description": "πΌοΈ Explore a famous art museum with a guided tour"},
{"time": "Evening", "description": "π· Dine at a rooftop restaurant with local wine"}
]
},
...
]
}`;app/appwrite/auth.ts
import { ID, OAuthProvider, Query } from "appwrite";
import { account, database, appwriteConfig } from "~/appwrite/client";
import { redirect } from "react-router";
export const getExistingUser = async (id: string) => {
try {
const { documents, total } = await database.listDocuments(
appwriteConfig.databaseId,
appwriteConfig.userCollectionId,
[Query.equal("accountId", id)]
);
return total > 0 ? documents[0] : null;
} catch (error) {
console.error("Error fetching user:", error);
return null;
}
};
export const storeUserData = async () => {
try {
const user = await account.get();
if (!user) throw new Error("User not found");
const { providerAccessToken } = (await account.getSession("current")) || {};
const profilePicture = providerAccessToken
? await getGooglePicture(providerAccessToken)
: null;
const createdUser = await database.createDocument(
appwriteConfig.databaseId,
appwriteConfig.userCollectionId,
ID.unique(),
{
accountId: user.$id,
email: user.email,
name: user.name,
imageUrl: profilePicture,
joinedAt: new Date().toISOString(),
}
);
if (!createdUser.$id) redirect("/sign-in");
} catch (error) {
console.error("Error storing user data:", error);
}
};
const getGooglePicture = async (accessToken: string) => {
try {
const response = await fetch(
"https://people.googleapis.com/v1/people/me?personFields=photos",
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!response.ok) throw new Error("Failed to fetch Google profile picture");
const { photos } = await response.json();
return photos?.[0]?.url || null;
} catch (error) {
console.error("Error fetching Google picture:", error);
return null;
}
};
export const loginWithGoogle = async () => {
try {
account.createOAuth2Session(
OAuthProvider.Google,
`${window.location.origin}/`,
`${window.location.origin}/404`
);
} catch (error) {
console.error("Error during OAuth2 session creation:", error);
}
};
export const logoutUser = async () => {
try {
await account.deleteSession("current");
} catch (error) {
console.error("Error during logout:", error);
}
};
export const getUser = async () => {
try {
const user = await account.get();
if (!user) return redirect("/sign-in");
const { documents } = await database.listDocuments(
appwriteConfig.databaseId,
appwriteConfig.userCollectionId,
[
Query.equal("accountId", user.$id),
Query.select(["name", "email", "imageUrl", "joinedAt", "accountId"]),
]
);
return documents.length > 0 ? documents[0] : redirect("/sign-in");
} catch (error) {
console.error("Error fetching user:", error);
return null;
}
};app/routes/admin/create-trip.tsx
import { useState } from "react";
import { useNavigate } from "react-router";
import { ButtonComponent } from "@syncfusion/ej2-react-buttons";
import { ComboBoxComponent } from "@syncfusion/ej2-react-dropdowns";
import {
LayerDirective,
LayersDirective,
MapsComponent,
} from "@syncfusion/ej2-react-maps";
import { Header } from "~/components";
import { comboBoxItems, selectItems } from "~/constants";
import { world_map } from "~/constants/world_map";
import type { Route } from "./+types/create-trip";
import { account } from "~/appwrite/client";
import { cn, formatKey } from "~/lib/utils";
export function meta() {
return [
{ title: "Create Trip" },
{ name: "description", content: "Create a Personalized Trip" },
];
}
export async function loader() {
const response = await fetch("https://restcountries.com/v3.1/all");
const data = await response.json();
return data.map((country: any) => ({
name: country.flag + country.name.common,
coordinates: country.latlng,
value: country.name.common,
openStreetMap: country.maps?.openStreetMaps,
})) as Country[];
}
const CreateTrip = ({ loaderData }: Route.ComponentProps) => {
const navigate = useNavigate();
const countries = loaderData as Country[];
const [formData, setFormData] = useState<TripFormData>({
country: countries[0]?.name || "",
travelStyle: "",
interest: "",
budget: "",
duration: 0,
groupType: "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChange = (key: keyof TripFormData, value: string | number) =>
setFormData({ ...formData, [key]: value });
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
if (
!formData.country ||
!formData.travelStyle ||
!formData.interest ||
!formData.budget ||
!formData.groupType
) {
setError("Please provide input for all fields");
setLoading(false);
return;
}
if (formData.duration < 1 || formData.duration > 10) {
setError("Duration must be between 1 and 10 days");
setLoading(false);
return;
}
const user = await account.get();
if (!user.$id) {
console.error("User not authenticated");
setLoading(false);
return;
}
try {
const response = await fetch("/api/create-trip", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
country: formData.country,
numberOfDays: formData.duration,
travelStyle: formData.travelStyle,
interests: formData.interest,
budget: formData.budget,
groupType: formData.groupType,
userId: user.$id,
}),
});
const result: CreateTripResponse = await response.json();
if (result?.id) navigate(`/trips/${result.id}`);
else console.error("Failed to generate itinerary");
} catch (error) {
console.error("Error generating itinerary:", error);
} finally {
setLoading(false);
}
};
const mapData = [
{
country: formData.country,
color: "#EA382E",
coordinates:
countries.find((c: Country) => c.name === formData.country)
?.coordinates || [],
},
];
const countryData = countries.map((country) => ({
text: country.name,
value: country.value,
}));
return (
<main className="flex flex-col gap-10 pb-20 wrapper">
<Header
title="Add a New Trip"
description="View and edit AI-generated travel plans"
/>
<section className="mt-2.5 wrapper-md">
<form className="trip-form" onSubmit={handleSubmit}>
<div>
<label htmlFor="country">Country</label>
<ComboBoxComponent
id="country"
dataSource={countryData}
fields={{ text: "text", value: "value" }}
placeholder="Select a Country"
change={(e: { value: string | undefined }) => {
if (e.value) {
handleChange("country", e.value);
}
}}
className="combo-box"
allowFiltering={true}
filtering={(e) => {
const query = e.text.toLowerCase();
e.updateData(
countries
.filter((country) =>
country.name.toLowerCase().includes(query)
)
.map((country) => ({
text: country.name,
value: country.value,
}))
);
}}
/>
</div>
<div>
<label htmlFor="duration">Duration</label>
<input
id="duration"
name="duration"
onChange={(e) => handleChange("duration", Number(e.target.value))}
placeholder="Enter number of days (e.g., 5, 12)"
className="form-input placeholder:text-gray-100"
/>
</div>
{selectItems.map((key) => (
<div key={key}>
<label htmlFor={key}>{formatKey(key)}</label>
<ComboBoxComponent
id={key}
dataSource={comboBoxItems[key].map((item) => ({
text: item,
value: item,
}))}
fields={{ text: "text", value: "value" }}
placeholder={`Select ${key}`}
change={(e: { value: string | undefined }) => {
if (e.value) {
handleChange(key, e.value);
}
}}
allowFiltering={true}
filtering={(e) => {
const query = e.text.toLowerCase();
e.updateData(
comboBoxItems[key]
.filter((item) => item.toLowerCase().includes(query))
.map((item) => ({ text: item, value: item }))
);
}}
className="combo-box"
/>
</div>
))}
<div>
<label htmlFor="location">Location on map</label>
<MapsComponent>
<LayersDirective>
<LayerDirective
shapeData={world_map}
dataSource={mapData}
shapePropertyPath="name"
shapeDataPath="country"
shapeSettings={{ colorValuePath: "color", fill: "#E5E5E5" }}
/>
</LayersDirective>
</MapsComponent>
</div>
<div className="bg-gray-200 h-px w-full" />
{error && (
<div className="error">
<p>{error}</p>
</div>
)}
<footer className="px-6 w-full">
<ButtonComponent
type="submit"
className="button-class !h-12 !w-full"
disabled={loading}
>
<img
src={`/assets/icons/${loading ? "loader.svg" : "magic-star.svg"}`}
alt="magic star"
className={cn("size-5", { "animate-spin": loading })}
/>
<span className="p-16-semibold text-white">
{loading ? "Generating..." : "Generate Itinerary"}
</span>
</ButtonComponent>
</footer>
</form>
</section>
</main>
);
};
export default CreateTrip;app/appwrite/dashboard.ts
import { parseTripData } from "~/lib/utils";
import { database, appwriteConfig } from "./client";
interface Document {
[key: string]: any;
}
type FilterByDate = (
items: Document[],
key: string,
start: string,
end?: string
) => number;
export const getUsersAndTripsStats = async (): Promise<DashboardStats> => {
const d = new Date();
const startCurrent = new Date(d.getFullYear(), d.getMonth(), 1).toISOString();
const startPrev = new Date(d.getFullYear(), d.getMonth() -1, 1).toISOString();
const endPrev = new Date(d.getFullYear(), d.getMonth(), 0).toISOString();
const [users, trips] = await Promise.all([
database.listDocuments(
appwriteConfig.databaseId,
appwriteConfig.userCollectionId
),
database.listDocuments(
appwriteConfig.databaseId,
appwriteConfig.tripCollectionId
),
])
const filterByDate: FilterByDate = (items, key, start, end) => items.filter((item) => (
item[key] >= start && (!end || item[key] <= end)
)).length;
const filterUsersByRole = (role: string) => {
return users.documents.filter((u: Document) => u.status === role)
}
return {
totalUsers: users.total,
usersJoined: {
currentMonth: filterByDate(
users.documents,
'joinedAt',
startCurrent,
undefined
),
lastMonth: filterByDate(
users.documents,
'joinedAt',
startPrev,
endPrev
)
},
userRole: {
total: filterUsersByRole('user').length,
currentMonth: filterByDate(
filterUsersByRole('user'),
'joinedAt',
startCurrent,
undefined
),
lastMonth: filterByDate(
filterUsersByRole('user'),
'joinedAt',
startPrev,
endPrev
)
},
totalTrips: trips.total,
tripsCreated: {
currentMonth: filterByDate(
trips.documents,
'createdAt',
startCurrent,
undefined
),
lastMonth: filterByDate(
filterUsersByRole('user'),
'joinedAt',
startPrev,
endPrev
)
},
}
}
export const getUserGrowthPerDay = async () => {
const users = await database.listDocuments(
appwriteConfig.databaseId,
appwriteConfig.userCollectionId
);
const userGrowth = users.documents.reduce(
(acc: { [key: string]: number }, user: Document) => {
const date = new Date(user.joinedAt);
const day = date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
acc[day] = (acc[day] || 0) + 1;
return acc;
},
{}
);
return Object.entries(userGrowth).map(([day, count]) => ({
count: Number(count),
day,
}));
};
export const getTripsCreatedPerDay = async () => {
const trips = await database.listDocuments(
appwriteConfig.databaseId,
appwriteConfig.tripCollectionId
);
const tripsGrowth = trips.documents.reduce(
(acc: { [key: string]: number }, trip: Document) => {
const date = new Date(trip.createdAt);
const day = date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
acc[day] = (acc[day] || 0) + 1;
return acc;
},
{}
);
return Object.entries(tripsGrowth).map(([day, count]) => ({
count: Number(count),
day,
}));
};
export const getTripsByTravelStyle = async () => {
const trips = await database.listDocuments(
appwriteConfig.databaseId,
appwriteConfig.tripCollectionId
);
const travelStyleCounts = trips.documents.reduce(
(acc: { [key: string]: number }, trip: Document) => {
const tripDetail = parseTripData(trip.tripDetails);
if (tripDetail && tripDetail.travelStyle) {
const travelStyle = tripDetail.travelStyle;
acc[travelStyle] = (acc[travelStyle] || 0) + 1;
}
return acc;
},
{}
);
return Object.entries(travelStyleCounts).map(([travelStyle, count]) => ({
count: Number(count),
travelStyle,
}));
};app/routes/admin/dashboard.tsx
import {Header, StatsCard, TripCard} from "../../../components";
import {getAllUsers, getUser} from "~/appwrite/auth";
import type { Route } from './+types/dashboard';
import {getTripsByTravelStyle, getUserGrowthPerDay, getUsersAndTripsStats} from "~/appwrite/dashboard";
import {getAllTrips} from "~/appwrite/trips";
import {parseTripData} from "~/lib/utils";
import {
Category,
ChartComponent,
ColumnSeries,
DataLabel, SeriesCollectionDirective, SeriesDirective,
SplineAreaSeries,
Tooltip
} from "@syncfusion/ej2-react-charts";
import {ColumnDirective, ColumnsDirective, GridComponent, Inject} from "@syncfusion/ej2-react-grids";
import {tripXAxis, tripyAxis, userXAxis, useryAxis} from "~/constants";
import {redirect} from "react-router";
export const clientLoader = async () => {
const [
user,
dashboardStats,
trips,
userGrowth,
tripsByTravelStyle,
allUsers,
] = await Promise.all([
await getUser(),
await getUsersAndTripsStats(),
await getAllTrips(4, 0),
await getUserGrowthPerDay(),
await getTripsByTravelStyle(),
await getAllUsers(4, 0),
])
const allTrips = trips.allTrips.map(({ $id, tripDetails, imageUrls }) => ({
id: $id,
...parseTripData(tripDetails),
imageUrls: imageUrls ?? []
}))
const mappedUsers: UsersItineraryCount[] = allUsers.users.map((user) => ({
imageUrl: user.imageUrl,
name: user.name,
count: user.itineraryCount ?? Math.floor(Math.random() * 10),
}))
return {
user,
dashboardStats,
allTrips,
userGrowth,
tripsByTravelStyle,
allUsers: mappedUsers
}
}
const Dashboard = ({ loaderData }: Route.ComponentProps) => {
const user = loaderData.user as User | null;
const { dashboardStats, allTrips, userGrowth, tripsByTravelStyle, allUsers } = loaderData;
const trips = allTrips.map((trip) => ({
imageUrl: trip.imageUrls[0],
name: trip.name,
interest: trip.interests,
}))
const usersAndTrips = [
{
title: 'Latest user signups',
dataSource: allUsers,
field: 'count',
headerText: 'Trips created'
},
{
title: 'Trips based on interests',
dataSource: trips,
field: 'interest',
headerText: 'Interests'
}
]
return (
<main className="dashboard wrapper">
<Header
title={`Welcome ${user?.name ?? 'Guest'} π`}
description="Track activity, trends and popular destinations in real time"
/>
<section className="flex flex-col gap-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 w-full">
<StatsCard
headerTitle="Total Users"
total={dashboardStats.totalUsers}
currentMonthCount={dashboardStats.usersJoined.currentMonth}
lastMonthCount={dashboardStats.usersJoined.lastMonth}
/>
<StatsCard
headerTitle="Total Trips"
total={dashboardStats.totalTrips}
currentMonthCount={dashboardStats.tripsCreated.currentMonth}
lastMonthCount={dashboardStats.tripsCreated.lastMonth}
/>
<StatsCard
headerTitle="Active Users"
total={dashboardStats.userRole.total}
currentMonthCount={dashboardStats.userRole.currentMonth}
lastMonthCount={dashboardStats.userRole.lastMonth}
/>
</div>
</section>
<section className="container">
<h1 className="text-xl font-semibold text-dark-100">Created Trips</h1>
<div className='trip-grid'>
{allTrips.map((trip) => (
<TripCard
key={trip.id}
id={trip.id.toString()}
name={trip.name!}
imageUrl={trip.imageUrls[0]}
location={trip.itinerary?.[0]?.location ?? ''}
tags={[trip.interests!, trip.travelStyle!]}
price={trip.estimatedPrice!}
/>
))}
</div>
</section>
<section className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<ChartComponent
id="chart-1"
primaryXAxis={userXAxis}
primaryYAxis={useryAxis}
title="User Growth"
tooltip={{ enable: true}}
>
<Inject services={[ColumnSeries, SplineAreaSeries, Category, DataLabel, Tooltip]} />
<SeriesCollectionDirective>
<SeriesDirective
dataSource={userGrowth}
xName="day"
yName="count"
type="Column"
name="Column"
columnWidth={0.3}
cornerRadius={{topLeft: 10, topRight: 10}}
/>
<SeriesDirective
dataSource={userGrowth}
xName="day"
yName="count"
type="SplineArea"
name="Wave"
fill="rgba(71, 132, 238, 0.3)"
border={{ width: 2, color: '#4784EE'}}
/>
</SeriesCollectionDirective>
</ChartComponent>
<ChartComponent
id="chart-2"
primaryXAxis={tripXAxis}
primaryYAxis={tripyAxis}
title="Trip Trends"
tooltip={{ enable: true}}
>
<Inject services={[ColumnSeries, SplineAreaSeries, Category, DataLabel, Tooltip]} />
<SeriesCollectionDirective>
<SeriesDirective
dataSource={tripsByTravelStyle}
xName="travelStyle"
yName="count"
type="Column"
name="day"
columnWidth={0.3}
cornerRadius={{topLeft: 10, topRight: 10}}
/>
</SeriesCollectionDirective>
</ChartComponent>
</section>
<section className="user-trip wrapper">
{usersAndTrips.map(({ title, dataSource, field, headerText}, i) => (
<div key={i} className="flex flex-col gap-5">
<h3 className="p-20-semibold text-dark-100">{title}</h3>
<GridComponent dataSource={dataSource} gridLines="None">
<ColumnsDirective>
<ColumnDirective
field="name"
headerText="Name"
width="200"
textAlign="Left"
template={(props: UserData) => (
<div className="flex items-center gap-1.5 px-4">
<img src={props.imageUrl} alt="user" className="rounded-full size-8 aspect-square" referrerPolicy="no-referrer" />
<span>{props.name}</span>
</div>
)}
/>
<ColumnDirective
field={field}
headerText={headerText}
width="150"
textAlign="Left"
/>
</ColumnsDirective>
</GridComponent>
</div>
))}
</section>
</main>
)
}
export default DashboardAssets used in the project can be found here.
Advance your skills with Next.js Pro Course
Enjoyed creating this project? Dive deeper into JSM's PRO courses for a richer learning adventure. They're packed with detailed explanations, cool features, and exercises to boost your skills. Give it a go!