Merge branch 'origin/main' into feature/faculty-directory

This commit is contained in:
Buvan
2026-07-23 08:22:24 +05:30
58 changed files with 5310 additions and 395 deletions

8
.gitignore vendored
View File

@@ -22,3 +22,11 @@ dist-ssr
*.njsproj
*.sln
*.sw?
*.db
# Environments
.env
.env.local
.env.*
.env.development
.env.production

View File

@@ -15,7 +15,7 @@ Welcome to the team! This guide explains how to get set up, which Git branch you
2. **Select your module's branch**:
Find your assigned module below, and check out your branch:
```bash
git checkout feature/<your-branch-name>
```
3. **Install frontend dependencies**:

14
backend/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"configurations": [
{
"type": "java",
"name": "Spring Boot-PortalApplication<portal>",
"request": "launch",
"cwd": "${workspaceFolder}",
"mainClass": "com.rit.portal.PortalApplication",
"projectName": "portal",
"args": "",
"envFile": "${workspaceFolder}/.env"
}
]
}

View File

@@ -1,35 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.1</version>
<relativePath/> <!-- lookup parent from repository -->
<version>3.4.13</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>com.rit</groupId>
<artifactId>portal</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Freshers Hub Backend</name>
<description>Centralized Spring Boot backend for RIT Freshers Hub</description>
<url/>
<url />
<licenses>
<license/>
<license />
</licenses>
<developers>
<developer/>
<developer />
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
<connection />
<developerConnection />
<tag />
<url />
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Web MVC starter for building REST APIs -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -1,12 +1,21 @@
package com.rit.portal.config;
import com.rit.portal.entity.NotePyq;
import com.rit.portal.entity.BusRoute;
import com.rit.portal.entity.BusStop;
import com.rit.portal.repository.NotePyqRepository;
import com.rit.portal.repository.BusRouteRepository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@Component
public class DataInitializer implements CommandLineRunner {
@@ -14,6 +23,9 @@ public class DataInitializer implements CommandLineRunner {
@Autowired
private NotePyqRepository noteRepository;
@Autowired
private BusRouteRepository busRouteRepository;
@Override
public void run(String... args) throws Exception {
if (noteRepository.count() == 0) {
@@ -109,5 +121,52 @@ public class DataInitializer implements CommandLineRunner {
));
System.out.println("🌱 Database successfully seeded with Notes & PYQs test data!");
}
// Seed Bus Routes
if (busRouteRepository.count() == 0) {
try {
ObjectMapper mapper = new ObjectMapper();
InputStream is = getClass().getResourceAsStream("/bus_routes.json");
if (is != null) {
List<Map<String, Object>> routesList = mapper.readValue(is, new TypeReference<List<Map<String, Object>>>() {});
List<BusRoute> routesToSave = new ArrayList<>();
for (Map<String, Object> routeMap : routesList) {
BusRoute br = BusRoute.builder()
.number((String) routeMap.get("number"))
.name((String) routeMap.get("name"))
.from((String) routeMap.get("from"))
.to((String) routeMap.get("to"))
.departureTime((String) routeMap.get("departureTime"))
.arrivalTime((String) routeMap.get("arrivalTime"))
.color((String) routeMap.get("color"))
.build();
List<Map<String, String>> stopsList = (List<Map<String, String>>) routeMap.get("stops");
List<BusStop> stops = new ArrayList<>();
if (stopsList != null) {
for (int i = 0; i < stopsList.size(); i++) {
Map<String, String> stopMap = stopsList.get(i);
stops.add(BusStop.builder()
.route(br)
.name(stopMap.get("name"))
.time(stopMap.get("time"))
.stopOrder(i + 1)
.build());
}
}
br.setStops(stops);
routesToSave.add(br);
}
busRouteRepository.saveAll(routesToSave);
System.out.println("🌱 Database successfully seeded with " + routesToSave.size() + " Bus Routes and stops!");
} else {
System.err.println("⚠️ Could not find bus_routes.json in resources!");
}
} catch (Exception e) {
System.err.println("❌ Failed to seed bus routes: " + e.getMessage());
e.printStackTrace();
}
}
}
}

View File

@@ -10,7 +10,7 @@ public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173", "http://localhost:3000")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);

View File

@@ -0,0 +1,20 @@
package com.rit.portal.controller;
import com.rit.portal.entity.BusRoute;
import com.rit.portal.repository.BusRouteRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/bus-routes")
public class BusRouteController {
@Autowired
private BusRouteRepository busRouteRepository;
@GetMapping
public List<BusRoute> getAllBusRoutes() {
return busRouteRepository.findAll();
}
}

View File

@@ -0,0 +1,97 @@
package com.rit.portal.controller;
import com.rit.portal.entity.CommunityAnswer;
import com.rit.portal.entity.CommunityQuestion;
import com.rit.portal.repository.CommunityAnswerRepository;
import com.rit.portal.repository.CommunityQuestionRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/questions")
public class CommunityQuestionController {
@Autowired
private CommunityQuestionRepository questionRepository;
@Autowired
private CommunityAnswerRepository answerRepository;
private final RestTemplate restTemplate = new RestTemplate();
private static final String TELEGRAM_BOT_URL = "http://localhost:8082/send_question";
@GetMapping
public List<CommunityQuestion> getAllQuestions() {
return questionRepository.findAll();
}
@GetMapping("/paged")
public org.springframework.data.domain.Page<CommunityQuestion> getPagedQuestions(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "5") int size) {
return questionRepository.findAll(
org.springframework.data.domain.PageRequest.of(
page,
size,
org.springframework.data.domain.Sort.by("createdAt").descending()
)
);
}
@PostMapping
public CommunityQuestion createQuestion(@RequestBody CommunityQuestion question) {
if (question.getUpvotes() == null) question.setUpvotes(0);
if (question.getIsAnswered() == null) question.setIsAnswered(false);
question.setCreatedAt(LocalDateTime.now());
CommunityQuestion saved = questionRepository.save(question);
// Notify Telegram bot in a background thread to keep it robust and non-blocking
new Thread(() -> {
try {
Map<String, Object> payload = new HashMap<>();
payload.put("question_id", saved.getId());
payload.put("title", saved.getTitle());
payload.put("body", saved.getBody());
payload.put("author", saved.getAuthor());
restTemplate.postForEntity(TELEGRAM_BOT_URL, payload, String.class);
} catch (Exception e) {
System.err.println("Failed to notify Telegram Bot intermediary: " + e.getMessage());
}
}).start();
return saved;
}
@PostMapping("/{id}/answers")
public ResponseEntity<CommunityAnswer> addAnswer(@PathVariable Integer id, @RequestBody CommunityAnswer answer) {
return questionRepository.findById(id).map(question -> {
answer.setQuestion(question);
if (answer.getUpvotes() == null) answer.setUpvotes(0);
if (answer.getIsAccepted() == null) answer.setIsAccepted(false);
answer.setCreatedAt(LocalDateTime.now());
CommunityAnswer savedAnswer = answerRepository.save(answer);
question.setIsAnswered(true);
questionRepository.save(question);
return ResponseEntity.ok(savedAnswer);
}).orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{id}/upvote")
public ResponseEntity<CommunityQuestion> upvoteQuestion(@PathVariable Integer id) {
return questionRepository.findById(id).map(question -> {
question.setUpvotes(question.getUpvotes() + 1);
return ResponseEntity.ok(questionRepository.save(question));
}).orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -9,7 +9,6 @@ import java.util.List;
@RestController
@RequestMapping("/api/notes")
@CrossOrigin(origins = "*") // CrossOrigin configured globally, but added here for safety
public class NotePyqController {
@Autowired
@@ -41,7 +40,7 @@ public class NotePyqController {
// Increment downloads count
@PostMapping("/{id}/download")
public ResponseEntity<Void> incrementDownloads(@PathVariable Long id) {
public ResponseEntity<Void> incrementDownloads(@PathVariable Integer id) {
return noteRepository.findById(id).map(note -> {
note.setDownloadsCount(note.getDownloadsCount() + 1);
note.setFileType(note.getFileType()); // Keep dirty check

View File

@@ -0,0 +1,46 @@
package com.rit.portal.entity;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "bus_routes")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BusRoute {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "route_number", unique = true, nullable = false)
private String number;
@Column(name = "route_name", nullable = false)
private String name;
@Column(name = "start_point", nullable = false)
private String from;
@Column(name = "end_point", nullable = false)
private String to;
@Column(name = "departure_time", nullable = false)
private String departureTime;
@Column(name = "arrival_time", nullable = false)
private String arrivalTime;
@Column(name = "color_code")
private String color;
@OneToMany(mappedBy = "route", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@OrderBy("stopOrder ASC")
@Builder.Default
private List<BusStop> stops = new ArrayList<>();
}

View File

@@ -0,0 +1,33 @@
package com.rit.portal.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "bus_stops")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BusStop {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "route_id", nullable = false)
@JsonIgnore
private BusRoute route;
@Column(name = "stop_name", nullable = false)
private String name;
@Column(name = "arrival_time", nullable = false)
private String time;
@Column(name = "stop_order", nullable = false)
private Integer stopOrder;
}

View File

@@ -0,0 +1,40 @@
package com.rit.portal.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "community_answers")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CommunityAnswer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "question_id", nullable = false)
@JsonIgnore
private CommunityQuestion question;
@Column(nullable = false, columnDefinition = "TEXT")
private String body;
@Column(nullable = false)
private String author;
@Column(name = "upvotes")
private Integer upvotes = 0;
@Column(name = "is_accepted")
private Boolean isAccepted = false;
@Column(name = "created_at")
private LocalDateTime createdAt = LocalDateTime.now();
}

View File

@@ -0,0 +1,46 @@
package com.rit.portal.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "community_questions")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CommunityQuestion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String body;
@Column(nullable = false)
private String author;
@Column(name = "upvotes")
private Integer upvotes = 0;
@Column(name = "tags", columnDefinition = "text[]")
private List<String> tags = new ArrayList<>();
@Column(name = "is_answered")
private Boolean isAnswered = false;
@Column(name = "created_at")
private LocalDateTime createdAt = LocalDateTime.now();
@OneToMany(mappedBy = "question", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@Builder.Default
private List<CommunityAnswer> answers = new ArrayList<>();
}

View File

@@ -15,7 +15,7 @@ public class NotePyq {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Integer id;
@Column(nullable = false)
private String title;

View File

@@ -0,0 +1,10 @@
package com.rit.portal.repository;
import com.rit.portal.entity.BusRoute;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface BusRouteRepository extends JpaRepository<BusRoute, Integer> {
BusRoute findByNumber(String number);
}

View File

@@ -0,0 +1,9 @@
package com.rit.portal.repository;
import com.rit.portal.entity.BusStop;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface BusStopRepository extends JpaRepository<BusStop, Integer> {
}

View File

@@ -0,0 +1,9 @@
package com.rit.portal.repository;
import com.rit.portal.entity.CommunityAnswer;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CommunityAnswerRepository extends JpaRepository<CommunityAnswer, Integer> {
}

View File

@@ -0,0 +1,9 @@
package com.rit.portal.repository;
import com.rit.portal.entity.CommunityQuestion;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CommunityQuestionRepository extends JpaRepository<CommunityQuestion, Integer> {
}

View File

@@ -6,7 +6,7 @@ import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotePyqRepository extends JpaRepository<NotePyq, Long> {
public interface NotePyqRepository extends JpaRepository<NotePyq, Integer> {
List<NotePyq> findBySemester(Integer semester);
List<NotePyq> findByDepartment(String department);
List<NotePyq> findByFileType(String fileType);

View File

@@ -1,9 +1,7 @@
# ─── DATABASE CONNECTION CONFIGURATION ───
spring.datasource.url=jdbc:postgresql://localhost:5432/rit_freshers_hub?sslmode=disable
spring.datasource.username=postgres
spring.datasource.password=murugan06
spring.datasource.password=${DB_PASSWORD:murugan06}
# ─── JPA / HIBERNATE SETTINGS ───
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
com\rit\portal\config\DataInitializer.class
com\rit\portal\entity\CommunityQuestion$CommunityQuestionBuilder.class
com\rit\portal\entity\NotePyq$NotePyqBuilder.class
com\rit\portal\repository\CommunityAnswerRepository.class
com\rit\portal\entity\CommunityAnswer$CommunityAnswerBuilder.class
com\rit\portal\controller\NotePyqController.class
com\rit\portal\entity\CommunityQuestion.class
com\rit\portal\entity\NotePyq.class
com\rit\portal\repository\NotePyqRepository.class
com\rit\portal\entity\CommunityAnswer.class
com\rit\portal\controller\CommunityQuestionController.class
com\rit\portal\repository\CommunityQuestionRepository.class
com\rit\portal\config\WebConfig.class
com\rit\portal\PortalApplication.class

View File

@@ -0,0 +1,11 @@
C:\Users\deves\OneDrive\project_main\Freshers-Hub\backend\src\main\java\com\rit\portal\config\DataInitializer.java
C:\Users\deves\OneDrive\project_main\Freshers-Hub\backend\src\main\java\com\rit\portal\config\WebConfig.java
C:\Users\deves\OneDrive\project_main\Freshers-Hub\backend\src\main\java\com\rit\portal\controller\CommunityQuestionController.java
C:\Users\deves\OneDrive\project_main\Freshers-Hub\backend\src\main\java\com\rit\portal\controller\NotePyqController.java
C:\Users\deves\OneDrive\project_main\Freshers-Hub\backend\src\main\java\com\rit\portal\entity\CommunityAnswer.java
C:\Users\deves\OneDrive\project_main\Freshers-Hub\backend\src\main\java\com\rit\portal\entity\CommunityQuestion.java
C:\Users\deves\OneDrive\project_main\Freshers-Hub\backend\src\main\java\com\rit\portal\entity\NotePyq.java
C:\Users\deves\OneDrive\project_main\Freshers-Hub\backend\src\main\java\com\rit\portal\PortalApplication.java
C:\Users\deves\OneDrive\project_main\Freshers-Hub\backend\src\main\java\com\rit\portal\repository\CommunityAnswerRepository.java
C:\Users\deves\OneDrive\project_main\Freshers-Hub\backend\src\main\java\com\rit\portal\repository\CommunityQuestionRepository.java
C:\Users\deves\OneDrive\project_main\Freshers-Hub\backend\src\main\java\com\rit\portal\repository\NotePyqRepository.java

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
public/clubs/fusion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
public/clubs/helios.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/clubs/infinitus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
public/clubs/mediastic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/clubs/nippon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
public/clubs/nss.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
public/clubs/podx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
public/clubs/rotaract.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
public/clubs/steam.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
public/clubs/techspark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
public/clubs/vaarithi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
public/clubs/wec.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
public/clubs/wistem.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
public/clubs/yuva.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -3,7 +3,7 @@
-- 1. Notes & PYQs Module Table
CREATE TABLE IF NOT EXISTS notes_pyqs (
id SERIAL PRIMARY KEY,
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
subject VARCHAR(250) NOT NULL,
department VARCHAR(250) NOT NULL,

View File

@@ -9,7 +9,7 @@ interface FeatureCardProps {
}
export default function FeatureCard({ feature }: FeatureCardProps) {
const IconComponent = (LucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[feature.icon];
const IconComponent = (LucideIcons as unknown as Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>>)[feature.icon];
return (
<motion.div

View File

@@ -6,7 +6,7 @@ export const NAV_LINKS = [
{ label: 'Notes', path: '/notes' },
{ label: 'AI Assistant', path: '/ai-assistant' },
{ label: 'Campus', path: '/campus' },
{ label: 'Events', path: '/events' },
{ label: 'Clubs', path: '/events' },
{ label: 'Community', path: '/community' },
];
@@ -77,9 +77,9 @@ export const FEATURES: Feature[] = [
},
{
id: 'events',
icon: 'Calendar',
title: 'Clubs & Events',
description: 'Discover clubs, upcoming events, workshops, and cultural programs at RIT.',
icon: 'Users',
title: 'Student Clubs',
description: 'Discover official RIT student clubs, technology domains, leadership, and contacts.',
path: '/events',
color: '#EF4444',
bgColor: '#FEF2F2',
@@ -527,12 +527,300 @@ export const EVENTS_DATA: Event[] = [
// ─── Clubs ───────────────────────────────────────────────────────────────────
export const CLUBS_DATA: Club[] = [
{ id: '1', name: 'Coding Club', description: 'For programming enthusiasts', category: 'Technical', members: 120 },
{ id: '2', name: 'Robotics Club', description: 'Build and code robots', category: 'Technical', members: 85 },
{ id: '3', name: 'Photography Club', description: 'Capture campus moments', category: 'Creative', members: 60 },
{ id: '4', name: 'Music Club', description: 'Classical and contemporary music', category: 'Cultural', members: 95 },
{ id: '5', name: 'Drama Club', description: 'Theatre and performing arts', category: 'Cultural', members: 45 },
{ id: '6', name: 'NSS', description: 'National Service Scheme', category: 'Social', members: 200 },
{
id: 'stellar_space_tech',
name: 'STELLAR SPACE TECH CLUB',
description: 'Space technology, astronomy, satellite systems, and space science exploration at RIT.',
details: 'RIT Stellar Space Tech Club brings together space enthusiasts, students, and aspiring aerospace engineers to explore astronomy, satellite communication, rocketry, and cutting-edge space technology through workshops, observational sessions, and technical projects.',
category: 'Technical',
members: 94,
presidentName: 'Student Space Lead',
year: 'Senior Year',
contactEmail: 'spacetech@ritchennai.edu.in',
contactPhone: '+91 98765 43210',
icon: 'Rocket',
logoUrl: '/clubs/stellar_space_tech.png',
socialLinks: {
instagram: 'https://www.instagram.com/stellarspacetechclub/',
},
},
{
id: 'podx',
name: 'podX-RIT (PODCAST CLUB)',
description: 'Real talks. Raw feels. Because every story deserves to be heard. Official Podcast Club of RIT.',
details: 'podX-RIT is the official Podcast and Radio Club of Rajalakshmi Institute of Technology. Bringing student stories, Switcheroo interviews, RJ sessions, and inspiring student & faculty conversations to life.',
category: 'Creative',
members: 1097,
presidentName: 'Lead RJ / Podcaster',
year: 'Senior Year',
contactEmail: 'podx@ritchennai.edu.in',
contactPhone: '+91 98765 43211',
icon: 'Mic',
logoUrl: '/clubs/podx.png',
socialLinks: {
instagram: 'https://www.instagram.com/podxrit/',
website: 'https://linktr.ee/podxrit',
},
},
{
id: 'nss',
name: 'NSS OF RIT',
description: 'National Service Scheme "Not Me, But You." Official Social Service Club of RIT.',
details: 'NSS of Rajalakshmi Institute of Technology engages students in community service, social awareness drives, blood donation camps, road safety campaigns, and nation-building initiatives.',
category: 'Social',
members: 1306,
presidentName: 'NSS Student Lead',
year: 'Final Year',
contactEmail: 'nss@ritchennai.edu.in',
contactPhone: '+91 98765 43212',
icon: 'HeartHandshake',
logoUrl: '/clubs/nss.png',
socialLinks: {
instagram: 'https://www.instagram.com/nss.rit/',
},
},
{
id: 'artist_league',
name: 'ARTIST LEAGUE (CULTURAL CELL)',
description: 'Official Cultural Cell of RIT covering Dance (R-Square), Band (Euphoria), Rap & Classical.',
details: 'Artist League is the umbrella cultural organization of RIT incorporating R-Square (Dance), Euphoria (Band), Raptology (Rap), and Classic Beat (Classical). It organizes cultural fests, talent shows, and 60 Seconds to Fame competitions.',
category: 'Creative',
members: 1093,
presidentName: 'Cultural Secretary',
year: 'Final Year',
contactEmail: 'artistleague@ritchennai.edu.in',
contactPhone: '+91 98765 43213',
icon: 'Sparkles',
logoUrl: '/clubs/artist_league.png',
socialLinks: {
instagram: 'https://www.instagram.com/artist_league_rit/',
},
},
{
id: 'infinitus',
name: 'INFINITUS CLUB (MATHS CLUB)',
description: 'Where Logic Meets Innovation. Mathematics workshops, PiDoku, GraphQuest & tech competitions.',
details: 'Infinitus Club is the official Mathematics & Aptitude Club of RIT, hosting Pi-Quest, PiDoku Decode π, GraphQuest, logic puzzles, speed calculations, and analytical problem-solving workshops.',
category: 'Technical',
members: 557,
presidentName: 'Mathematics Lead',
year: 'Third Year',
contactEmail: 'infinitus@ritchennai.edu.in',
contactPhone: '+91 98765 43214',
icon: 'Calculator',
logoUrl: '/clubs/infinitus.png',
socialLinks: {
instagram: 'https://www.instagram.com/infinitus_club.rit/',
whatsapp: 'https://chat.whatsapp.com/LeCKYtgzdzJ5E6sL0WjYIHf',
},
},
{
id: 'vaarithi',
name: 'VAARITHI TAMIL CLUB',
description: 'இராசலட்சுமி தொழில்நுட்பக் கல்லூரியின் மாணவர் தமிழ் மன்றம் (RIT Tamil Mandram).',
details: 'Vaarithi Muthamizh Mandram of RIT celebrates Tamil literature, poetry, debate (Tamizhsudar), Tamil New Year celebrations, and preserving rich Tamil culture and heritage.',
category: 'Creative',
members: 848,
presidentName: 'Tamil Mandram Lead',
year: 'Final Year',
contactEmail: 'vaarithi@ritchennai.edu.in',
contactPhone: '+91 98765 43215',
icon: 'BookOpen',
logoUrl: '/clubs/vaarithi.png',
socialLinks: {
instagram: 'https://www.instagram.com/vaarithi_rit/',
},
},
{
id: 'fusion',
name: 'FUSION CLUB (ENGLISH & LITERATURE)',
description: 'A platform to delve into the beauty of language, literature, essays, and power of words.',
details: 'Fusion Club of RIT is the English Literary Club organizing essay writing contests, Lips Dont Lie games, Fusion Treasure Hunt, debates, and creative writing workshops.',
category: 'Creative',
members: 815,
presidentName: 'Literary President',
year: 'Third Year',
contactEmail: 'fusionclub@ritchennai.edu.in',
contactPhone: '+91 98765 43216',
icon: 'Languages',
logoUrl: '/clubs/fusion.png',
socialLinks: {
instagram: 'https://www.instagram.com/fusionclub_rit/',
},
},
{
id: 'nippon',
name: 'NIPPON CLUB (JAPANESE CULTURE)',
description: 'Celebrating Japanese culture, language, anime, movie review nights & vibrant community.',
details: 'Nippon Club of RIT brings Japanese language learning, Kizuna Canvas relay drawing, Koe Nashi voice challenge, anime screenings, and Japanese cultural immersion to RIT students.',
category: 'Creative',
members: 649,
presidentName: 'Nippon President',
year: 'Third Year',
contactEmail: 'nippon@ritchennai.edu.in',
contactPhone: '+91 98765 43217',
icon: 'Globe',
logoUrl: '/clubs/nippon.png',
socialLinks: {
instagram: 'https://www.instagram.com/nippon_rit/',
},
},
{
id: 'unnat_bharat',
name: 'RIT UNNAT BHARAT ABHIYAN (UBA)',
description: 'Unite, socialize, thrive together! Official Social Impact & Rural Development Club.',
details: 'RIT Unnat Bharat Abhiyan (UBA) club focuses on sustainable village development, plantation drives, environmental awareness, and impactful social initiatives across rural Tamil Nadu.',
category: 'Social',
members: 1153,
presidentName: 'UBA Core Lead',
year: 'Final Year',
contactEmail: 'uba@ritchennai.edu.in',
contactPhone: '+91 98765 43218',
icon: 'Users',
logoUrl: '/clubs/unnat_bharat.png',
socialLinks: {
instagram: 'https://www.instagram.com/ritunnatbharatabhiyan/',
},
},
{
id: 'mediastic',
name: 'MEDIASTIC HUB (SOCIAL MEDIA CLUB)',
description: 'Official Social Media & Digital Content Creation Club of Rajalakshmi Institute of Technology.',
details: 'Mediastic Hub handles official video production, campus media coverage, event highlights for RobochipX & Quant-a-thon, photography exhibitions, and RIT digital channels.',
category: 'Creative',
members: 1351,
presidentName: 'Media Hub Lead',
year: 'Final Year',
contactEmail: 'mediastichub@ritchennai.edu.in',
contactPhone: '+91 98765 43219',
icon: 'Camera',
logoUrl: '/clubs/mediastic.png',
socialLinks: {
instagram: 'https://www.instagram.com/mediastichub_rit/',
youtube: 'https://www.youtube.com/@rajalakshmiinstituteoftech444',
},
},
{
id: 'helios',
name: 'HELIOS RIT (PHOTOGRAPHY CLUB)',
description: 'Capturing Life\'s Moments, One Frame at a Time. Showcasing cinematic shots & campus stories.',
details: 'Helios RIT Official is the premier photography and filmmaking club of RIT, capturing campus events, hosting photography contests, guest lectures, and showcasing cinematic stories of RIT.',
category: 'Creative',
members: 2455,
presidentName: 'Photography Lead',
year: 'Final Year',
contactEmail: 'helios@ritchennai.edu.in',
contactPhone: '+91 98765 43220',
icon: 'Camera',
logoUrl: '/clubs/helios.png',
socialLinks: {
instagram: 'https://www.instagram.com/helios.rit/',
},
},
{
id: 'techspark',
name: 'TECHSPARK CLUB RIT',
description: 'Where Tech meets creativity, Sparks fly! Fostering innovation in every byte.',
details: 'TechSpark Club RIT is the technical innovation club driving Web3 Camp Meets AI, GirlScript hackathons, Spark Booths, coding competitions, and hands-on developer projects.',
category: 'Technical',
members: 672,
presidentName: 'TechSpark Lead',
year: 'Third Year',
contactEmail: 'techspark@ritchennai.edu.in',
contactPhone: '+91 98765 43221',
icon: 'Zap',
logoUrl: '/clubs/techspark.png',
socialLinks: {
instagram: 'https://www.instagram.com/techspark.rit/',
whatsapp: 'https://chat.whatsapp.com/I8B5Wdybrew2dYYDrE4PG',
},
},
{
id: 'wec',
name: 'WOMEN EMPOWERMENT CELL (WEC)',
description: 'Empower • Excel • Elevate. Supporting women in technology, leadership & coding.',
details: 'Women Empowerment Cell (WEC RIT) organizes She Shines @ Hexaware coding challenges, Womens Empowerment Week, ZYRA Days, leadership seminars, and career development initiatives.',
category: 'Social',
members: 428,
presidentName: 'Pooja WYA',
year: 'Final Year',
contactEmail: 'wec@ritchennai.edu.in',
contactPhone: '+91 98765 43222',
icon: 'Sparkles',
logoUrl: '/clubs/wec.png',
socialLinks: {
instagram: 'https://www.instagram.com/wec.rit/',
},
},
{
id: 'rotaract',
name: 'ROTARACT CLUB OF RIT (RCRIT)',
description: 'Embrace the joy of giving. Official Rotaract organization for leadership & service.',
details: 'Rotaract Club of RIT (RCRIT) under Rotary International sponsors Bloom mental health seminars, Phoenix fests, RITARIQ 365, Ryla leadership camps, and community blood donation drives.',
category: 'Social',
members: 1813,
presidentName: 'Hari Balaji',
year: 'Final Year',
contactEmail: 'rotaract@ritchennai.edu.in',
contactPhone: '+91 98765 43223',
icon: 'HeartHandshake',
logoUrl: '/clubs/rotaract.png',
socialLinks: {
instagram: 'https://www.instagram.com/rcrit/',
},
},
{
id: 'yuva',
name: 'Yi-CH YUVA RIT',
description: 'Official Page of YUVA Club RIT under Young Indians (Yi). Inspiring youth leadership.',
details: 'Yi-CH YUVA RIT leads Chennai Get Talent, Safe Miles road safety awareness, Blogathons, Tree Plantation drives (500 Trees Mission), and youth leadership conventions.',
category: 'Social',
members: 1460,
presidentName: 'YUVA Student Chair',
year: 'Final Year',
contactEmail: 'yuva@ritchennai.edu.in',
contactPhone: '+91 98765 43224',
icon: 'Rocket',
logoUrl: '/clubs/yuva.png',
socialLinks: {
instagram: 'https://www.instagram.com/yuva_rit/',
},
},
{
id: 'wistem',
name: 'WiSTEM CLUB OF RIT',
description: 'Women in STEM Club. AI Quests, STEM Chronicles, CodeVsClick & maker empowering events.',
details: 'WiSTEM Club of RIT empowers women in Science, Technology, Engineering, and Mathematics through AI Quests, STEM Chronicles newsletter, CodeVsClick contests, and technical workshops.',
category: 'Technical',
members: 453,
presidentName: 'WiSTEM Lead',
year: 'Third Year',
contactEmail: 'wistem@ritchennai.edu.in',
contactPhone: '+91 98765 43225',
icon: 'Atom',
logoUrl: '/clubs/wistem.png',
socialLinks: {
instagram: 'https://www.instagram.com/wistem._.rit/',
website: 'https://www.rityatra.in/',
},
},
{
id: 'steam',
name: 'STEAM CLUB RIT',
description: 'Innovate - Collaborate - Elevate. Hands-on RobochipX, Quant-a-thon & AI/Chip design.',
details: 'STEAM Club RIT focuses on hands-on project creation, RobochipX 24-hour hackathons, Quant-a-thon quantum technology events, chip design, and robotics innovations.',
category: 'Technical',
members: 895,
presidentName: 'STEAM President',
year: 'Final Year',
contactEmail: 'steamclub@ritchennai.edu.in',
contactPhone: '+91 98765 43226',
icon: 'Printer',
logoUrl: '/clubs/steam.png',
socialLinks: {
instagram: 'https://www.instagram.com/steam_club.rit/',
},
},
];
// ─── Community Q&A ───────────────────────────────────────────────────────────

View File

@@ -157,7 +157,7 @@ export default function AIAssistant() {
background: msg.role === 'user' ? 'linear-gradient(135deg, #F97316, #FB923C)' : '#F8FAFC',
color: msg.role === 'user' ? 'white' : '#1E293B',
borderRadius: msg.role === 'user' ? '20px 20px 4px 20px' : '20px 20px 20px 4px',
border: msg.role === 'ai' ? '1px solid #E5E7EB' : 'none',
border: msg.role === 'assistant' ? '1px solid #E5E7EB' : 'none',
fontFamily: 'Inter, sans-serif',
}}
dangerouslySetInnerHTML={{ __html: formatContent(msg.content) }}

View File

@@ -1,12 +1,13 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Search, ChevronRight, MapPin, Users, BookOpen, Filter, ArrowUpDown } from 'lucide-react';
import { Search, ChevronRight, MapPin, Users, BookOpen, Filter, ArrowUpDown, Loader2 } from 'lucide-react';
import SectionTitle from '@/components/SectionTitle/SectionTitle';
import BusCard from '@/components/BusCard/BusCard';
import FacultyCard from '@/components/FacultyCard/FacultyCard';
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
import { BUS_ROUTES, FACULTY_DATA, CAMPUS_LOCATIONS, DEPARTMENTS } from '@/constants';
import { FACULTY_DATA, CAMPUS_LOCATIONS, DEPARTMENTS } from '@/constants';
import * as LucideIcons from 'lucide-react';
import type { BusRoute } from '@/types';
type Tab = 'map' | 'bus' | 'faculty';
@@ -16,6 +17,27 @@ export default function Campus() {
const [selectedDept, setSelectedDept] = useState('All Departments');
const [sortBy, setSortBy] = useState('Name A-Z');
const [busSearch, setBusSearch] = useState('');
const [busRoutes, setBusRoutes] = useState<BusRoute[]>([]);
const [loadingBus, setLoadingBus] = useState(true);
useEffect(() => {
if (activeTab === 'bus' && busRoutes.length === 0) {
setLoadingBus(true);
fetch('http://localhost:8080/api/bus-routes')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then((data) => {
setBusRoutes(data);
setLoadingBus(false);
})
.catch((err) => {
console.error('Error fetching bus routes:', err);
setLoadingBus(false);
});
}
}, [activeTab, busRoutes.length]);
const filteredAndSortedFaculty = useMemo(() => {
const filtered = FACULTY_DATA.filter((f) => {
@@ -38,7 +60,7 @@ export default function Campus() {
});
}, [searchFaculty, selectedDept, sortBy]);
const filteredRoutes = BUS_ROUTES.filter((r) =>
const filteredRoutes = busRoutes.filter((r) =>
r.name.toLowerCase().includes(busSearch.toLowerCase()) ||
r.from.toLowerCase().includes(busSearch.toLowerCase()) ||
r.number.toLowerCase().includes(busSearch.toLowerCase())
@@ -86,7 +108,7 @@ export default function Campus() {
{/* Tabs */}
<div className="flex gap-2 bg-white rounded-2xl p-2 border border-[#E5E7EB] mb-8 w-fit" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
{TABS.map((tab) => {
const Icon = (LucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[tab.icon];
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[tab.icon];
return (
<button
key={tab.id}
@@ -169,7 +191,7 @@ export default function Campus() {
{/* Quick Nav */}
<div className="p-6 grid grid-cols-4 sm:grid-cols-8 gap-4">
{CAMPUS_LOCATIONS.map((loc) => {
const Icon = (LucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[loc.icon];
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[loc.icon];
return (
<motion.button
key={loc.id}
@@ -204,13 +226,29 @@ export default function Campus() {
style={{ fontFamily: 'Inter, sans-serif' }}
/>
</div>
<StaggerContainer className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{filteredRoutes.map((route) => (
<StaggerItem key={route.id}>
<BusCard route={route} />
</StaggerItem>
))}
</StaggerContainer>
{loadingBus ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="w-8 h-8 text-[#F97316] animate-spin" />
<p className="text-sm text-[#94A3B8]" style={{ fontFamily: 'Poppins, sans-serif' }}>
Loading live bus routes from RIT Transport...
</p>
</div>
) : filteredRoutes.length === 0 ? (
<div className="text-center py-16 text-[#94A3B8]">
<p className="text-sm font-medium" style={{ fontFamily: 'Poppins, sans-serif' }}>
No bus routes found matching "{busSearch}"
</p>
</div>
) : (
<StaggerContainer className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{filteredRoutes.map((route) => (
<StaggerItem key={route.id}>
<BusCard route={route} />
</StaggerItem>
))}
</StaggerContainer>
)}
</motion.div>
)}

View File

@@ -1,31 +1,157 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MessageCircle, Heart, TrendingUp, ThumbsUp, Send,
Shield, Lock, Smile, ChevronRight, Search, Plus
Shield, Lock, Smile, ChevronRight, Search, Plus, User
} from 'lucide-react';
import SectionTitle from '@/components/SectionTitle/SectionTitle';
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer';
import { QUESTIONS_DATA, CONFESSIONS_DATA } from '@/constants';
import { formatDate } from '@/lib/utils';
type Tab = 'qa' | 'confession';
const TRENDING_TAGS = ['hostel', 'academics', 'clubs', 'campus', 'canteen', 'sports', 'placement', 'library'];
const AVATARS: Record<string, string> = {
'Priya S.': 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&h=120&q=80',
'Ravi K.': 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=120&h=120&q=80',
'Arun M.': 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&h=120&q=80',
};
const getAvatar = (author: string) => {
return AVATARS[author] || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(author)}`;
};
const getRelativeTime = (dateString: string) => {
if (!dateString) return 'just now';
// Normalize microsecond timestamps (e.g. 2026-07-22T13:28:18.174367) to standard millisecond precision
let normalized = dateString;
const dotIndex = dateString.indexOf('.');
if (dotIndex !== -1) {
const mainPart = dateString.substring(0, dotIndex);
let msPart = dateString.substring(dotIndex + 1);
// Strip any trailing non-digits (like timezone offsets Z or +05:30) for truncation, then keep first 3 digits
const nonDigitMatch = msPart.match(/\D/);
let suffix = '';
if (nonDigitMatch && nonDigitMatch.index !== undefined) {
suffix = msPart.substring(nonDigitMatch.index);
msPart = msPart.substring(0, nonDigitMatch.index);
}
normalized = `${mainPart}.${msPart.substring(0, 3)}${suffix}`;
}
const now = new Date().getTime(); // Dynamic local time
const past = new Date(normalized).getTime();
if (isNaN(past)) return 'just now';
const msPerMinute = 60 * 1000;
const msPerHour = msPerMinute * 60;
const msPerDay = msPerHour * 24;
const elapsed = now - past;
if (elapsed < msPerMinute) {
return 'just now';
} else if (elapsed < msPerHour) {
return Math.round(elapsed / msPerMinute) + 'm ago';
} else if (elapsed < msPerDay) {
return Math.round(elapsed / msPerHour) + 'h ago';
} else {
const days = Math.round(elapsed / msPerDay);
return days === 1 ? 'yesterday' : `${days}d ago`;
}
};
export default function Community() {
const PAGE_SIZE = 5;
const [activeTab, setActiveTab] = useState<Tab>('qa');
const [confessionText, setConfessionText] = useState('');
const [questionText, setQuestionText] = useState('');
const [authorName, setAuthorName] = useState('');
const [confessionPosted, setConfessionPosted] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
const [expandedQuestionIds, setExpandedQuestionIds] = useState<Set<string>>(new Set());
// Server-side pagination
const [currentPage, setCurrentPage] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [questions, setQuestions] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// Fallback static questions for when backend is unavailable
const [staticQuestions] = useState<any[]>(QUESTIONS_DATA);
const filteredQuestions = QUESTIONS_DATA.filter((q) =>
q.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.tags.some((t) => t.includes(searchQuery.toLowerCase()))
);
const toggleAnswers = (id: string) => {
setExpandedQuestionIds((prev) => {
const next = new Set(prev);
if (next.has(id.toString())) {
next.delete(id.toString());
} else {
next.add(id.toString());
}
return next;
});
};
const fetchQuestions = async (page = 0) => {
setLoading(true);
try {
const response = await fetch(`http://localhost:8080/api/questions/paged?page=${page}&size=${PAGE_SIZE}`);
if (response.ok) {
const data = await response.json();
// Spring Page response: { content: [], totalPages, totalElements, number }
setQuestions(data.content || []);
setTotalPages(data.totalPages || 1);
setCurrentPage(data.number ?? page);
} else {
console.error("Failed to fetch questions from backend: HTTP status", response.status);
// Fallback: slice static questions
const start = page * PAGE_SIZE;
setQuestions(staticQuestions.slice(start, start + PAGE_SIZE));
setTotalPages(Math.ceil(staticQuestions.length / PAGE_SIZE));
setCurrentPage(page);
}
} catch (error) {
console.error('Backend not available. Falling back to local static questions data.', error);
const start = page * PAGE_SIZE;
setQuestions(staticQuestions.slice(start, start + PAGE_SIZE));
setTotalPages(Math.ceil(staticQuestions.length / PAGE_SIZE));
setCurrentPage(page);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchQuestions(0);
}, []);
const getAnswersCount = (q: any) => {
if (Array.isArray(q.answers)) return q.answers.length;
if (typeof q.answers === 'number') return q.answers;
return 0;
};
const getVotesCount = (q: any) => {
const votesVal = typeof q.upvotes === 'number' ? q.upvotes : (typeof q.votes === 'number' ? q.votes : 0);
return votesVal;
};
// Client-side filter applied on top of current page (for search within page)
const filteredQuestions = questions.filter((q) => {
const titleVal = q.title || "";
const tagsVal = q.tags || [];
return titleVal.toLowerCase().includes(searchQuery.toLowerCase()) ||
tagsVal.some((t: string) => t.toLowerCase().includes(searchQuery.toLowerCase()));
});
const handlePageChange = (newPage: number) => {
setExpandedQuestionIds(new Set()); // collapse any open answers
fetchQuestions(newPage);
// Scroll back to the top of the questions list smoothly
document.getElementById('qa-questions-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleConfess = () => {
if (!confessionText.trim()) return;
@@ -34,34 +160,90 @@ export default function Community() {
setTimeout(() => setConfessionPosted(false), 3000);
};
const toggleLike = (id: string) => {
const toggleLike = async (id: string) => {
setLikedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
try {
await fetch(`http://localhost:8080/api/questions/${id}/upvote`, {
method: 'POST'
});
fetchQuestions();
} catch (error) {
console.log('Backend not available for upvote sync.', error);
}
};
const handlePostQuestion = async () => {
if (!questionText.trim()) return;
const displayAuthor = authorName.trim() || 'Anonymous';
const newQuestion = {
title: questionText.split('\n')[0].substring(0, 100) || "Q&A Question",
body: questionText,
author: displayAuthor,
tags: ['fresher', 'general'],
upvotes: 0,
isAnswered: false,
createdAt: new Date().toISOString(),
answers: []
};
try {
const response = await fetch('http://localhost:8080/api/questions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newQuestion.title,
body: newQuestion.body,
author: newQuestion.author,
tags: newQuestion.tags
})
});
if (response.ok) {
const saved = await response.json();
setQuestions(prev => {
// Prepend saved question and filter out duplicate placeholders
const filtered = prev.filter(q => q.id.toString() !== saved.id.toString());
return [saved, ...filtered];
});
setQuestionText('');
setAuthorName('');
} else {
setQuestions(prev => [{ ...newQuestion, id: String(Date.now()) }, ...prev]);
setQuestionText('');
setAuthorName('');
}
} catch (error) {
console.error("Error saving question:", error);
setQuestions(prev => [{ ...newQuestion, id: String(Date.now()) }, ...prev]);
setQuestionText('');
setAuthorName('');
}
};
return (
<div className="min-h-screen" style={{ backgroundColor: '#FAFAFA' }}>
<div className="min-h-screen" style={{ backgroundColor: '#FAFBFD' }}>
{/* Header */}
<div className="bg-white border-b border-[#E5E7EB] py-10">
<div className="bg-white border-b border-slate-100 py-12">
<div className="container-custom">
<h1 className="text-3xl md:text-4xl font-bold text-[#1E293B] mb-2" style={{ fontFamily: 'Playfair Display, serif' }}>
RIT{' '}
<span style={{ background: 'linear-gradient(135deg, #F97316, #FB923C)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
Community
</span>
<h1 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>
RIT Community
</h1>
<p className="text-[#475569]" style={{ fontFamily: 'Inter, sans-serif' }}>
<p className="text-slate-500 text-sm" style={{ fontFamily: 'Inter, sans-serif' }}>
Ask questions, share confessions, and connect with fellow RIT students.
</p>
</div>
</div>
<div className="container-custom py-8">
<div className="container-custom py-10">
{/* Tabs */}
<div className="flex gap-2 bg-white rounded-2xl p-2 border border-[#E5E7EB] mb-8 w-fit" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
<div className="flex gap-1.5 bg-slate-100 rounded-xl p-1.5 border border-slate-200/40 mb-8 w-fit">
{[
{ id: 'qa' as Tab, label: 'Freshers Q&A', icon: MessageCircle },
{ id: 'confession' as Tab, label: 'Confessions', icon: Heart },
@@ -69,19 +251,18 @@ export default function Community() {
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="relative flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all"
style={{ fontFamily: 'Poppins, sans-serif', color: activeTab === tab.id ? 'white' : '#475569' }}
className="relative flex items-center gap-2 px-4 py-2 rounded-lg text-[13px] font-medium transition-all cursor-pointer"
style={{ fontFamily: 'Poppins, sans-serif', color: activeTab === tab.id ? '#ffffff' : '#64748B' }}
>
{activeTab === tab.id && (
<motion.div
layoutId="community-tab"
className="absolute inset-0 rounded-xl"
style={{ background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
transition={{ type: 'spring', bounce: 0.2, duration: 0.4 }}
className="absolute inset-0 rounded-lg bg-slate-950"
transition={{ type: 'spring', bounce: 0.15, duration: 0.35 }}
/>
)}
<span className="relative z-10 flex items-center gap-2">
<tab.icon className="w-4 h-4" />
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
</span>
</button>
@@ -93,33 +274,42 @@ export default function Community() {
{activeTab === 'qa' && (
<motion.div
key="qa"
initial={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.3 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.25 }}
>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
{/* Ask Question Box */}
<div className="bg-white rounded-2xl border border-[#E5E7EB] p-5 mb-6" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
<h3 className="text-sm font-semibold text-[#1E293B] mb-3" style={{ fontFamily: 'Poppins, sans-serif' }}>Ask a Question</h3>
<div className="bg-white rounded-xl border border-slate-200/80 p-5 mb-8 shadow-xs">
<h3 className="text-sm font-semibold text-slate-900 mb-3 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>Ask a Question</h3>
<textarea
value={questionText}
onChange={(e) => setQuestionText(e.target.value)}
placeholder="What's on your mind? Ask your seniors anything about RIT..."
rows={3}
className="w-full border border-[#E5E7EB] rounded-xl p-3 text-sm text-[#1E293B] placeholder-[#94A3B8] focus:outline-none focus:border-[#F97316] resize-none transition-colors mb-3"
className="w-full border border-slate-200 rounded-xl p-3 text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-slate-400 focus:bg-white bg-slate-50/30 resize-none transition-all mb-3"
style={{ fontFamily: 'Inter, sans-serif' }}
/>
<input
type="text"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
placeholder="Your Name (optional)"
className="w-full md:w-64 border border-slate-200 rounded-xl px-3 py-2 text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-slate-400 focus:bg-white bg-slate-50/30 transition-all mb-4"
style={{ fontFamily: 'Inter, sans-serif' }}
/>
<div className="flex items-center justify-between">
<span className="text-xs text-[#94A3B8]" style={{ fontFamily: 'Inter, sans-serif' }}>
<span className="text-[11px] text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>
Your question will be visible to all students
</span>
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-white text-sm font-semibold"
style={{ fontFamily: 'Poppins, sans-serif', background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
onClick={handlePostQuestion}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl text-white text-[13px] font-semibold bg-[#F97316] hover:bg-[#EA580C] transition-colors cursor-pointer"
style={{ fontFamily: 'Poppins, sans-serif' }}
>
<Plus className="w-4 h-4" />
Post Question
@@ -127,92 +317,237 @@ export default function Community() {
</div>
</div>
{/* Search */}
<div className="relative mb-5">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94A3B8]" />
<div className="relative mb-6">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Search questions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-[#E5E7EB] bg-white text-sm text-[#1E293B] placeholder-[#94A3B8] focus:outline-none focus:border-[#F97316] transition-colors"
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white text-[13px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-slate-400 transition-colors"
style={{ fontFamily: 'Inter, sans-serif' }}
/>
</div>
{/* Questions */}
<StaggerContainer className="flex flex-col gap-4">
{filteredQuestions.map((q) => (
<StaggerItem key={q.id}>
<motion.div
whileHover={{ y: -2 }}
className="bg-white rounded-2xl border border-[#E5E7EB] p-5"
style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}
>
<div className="flex items-start gap-4">
{/* Votes */}
<div className="flex flex-col items-center gap-1 shrink-0">
<button
onClick={() => toggleLike(q.id)}
className="w-8 h-8 rounded-lg flex items-center justify-center transition-all"
style={{ backgroundColor: likedIds.has(q.id) ? '#FFF7ED' : '#F8FAFC' }}
>
<ThumbsUp className="w-4 h-4" style={{ color: likedIds.has(q.id) ? '#F97316' : '#94A3B8' }} />
</button>
<span className="text-xs font-semibold text-[#475569]">{q.votes + (likedIds.has(q.id) ? 1 : 0)}</span>
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-[#1E293B] mb-1.5 hover:text-[#F97316] cursor-pointer transition-colors"
style={{ fontFamily: 'Poppins, sans-serif' }}>
{q.title}
</h3>
<p className="text-xs text-[#94A3B8] mb-3 line-clamp-2" style={{ fontFamily: 'Inter, sans-serif' }}>{q.body}</p>
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
{q.tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-[#F8FAFC] text-[#94A3B8] border border-[#E5E7EB]"
style={{ fontFamily: 'Inter, sans-serif' }}
>
#{tag}
</span>
))}
</div>
<div className="flex items-center gap-3 text-xs text-[#94A3B8]">
<span className="flex items-center gap-1">
<MessageCircle className="w-3.5 h-3.5" />
{q.answers} answers
</span>
{q.isAnswered && (
<span className="px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-600 text-[10px] font-semibold"> Answered</span>
)}
</div>
<div id="qa-questions-list">
{loading ? (
<div className="flex flex-col gap-3">
{[...Array(PAGE_SIZE)].map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-slate-200/50 p-5 animate-pulse">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-slate-100 shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-3 w-1/4 rounded bg-slate-100" />
<div className="h-4 w-2/3 rounded bg-slate-100" />
<div className="h-3 w-full rounded bg-slate-100" />
</div>
</div>
</div>
</motion.div>
</StaggerItem>
))}
</StaggerContainer>
))}
</div>
) : (
<StaggerContainer key={`${currentPage}-${filteredQuestions.length}`} className="flex flex-col gap-4">
{filteredQuestions.map((q, idx) => {
const isFeatured = idx === 0 && searchQuery === '' && currentPage === 0;
return (
<StaggerItem key={q.id}>
<motion.div
whileHover={{ y: -0.5 }}
className={`bg-white rounded-xl border p-5 transition-all duration-300 hover:border-slate-350 hover:shadow-[0_8px_30px_rgba(17,24,39,0.015)] ${
isFeatured
? 'border-l-2 border-l-slate-800 border-slate-200'
: 'border-slate-200/50'
}`}
>
<div className="flex items-start gap-4">
{/* Avatar */}
<img
src={getAvatar(q.author)}
alt={q.author}
className="w-10 h-10 rounded-xl object-cover bg-slate-50 border border-slate-100 shrink-0"
/>
<div className="flex-1 min-w-0">
{/* Header / Meta */}
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="text-xs font-bold text-slate-800 hover:text-slate-900 transition-colors" style={{ fontFamily: 'Poppins, sans-serif' }}>{q.author}</span>
<span className="text-[10px] text-slate-300"></span>
<span className="text-[10px] text-slate-400 font-medium" style={{ fontFamily: 'Inter, sans-serif' }}>{getRelativeTime(q.createdAt)}</span>
{isFeatured && (
<span className="ml-auto bg-amber-50 text-amber-700 border border-amber-200/40 text-[9px] px-1.5 py-0.5 rounded font-semibold tracking-wide uppercase"> Popular</span>
)}
</div>
<h3 className={`font-bold text-slate-900 mb-1 hover:text-slate-700 cursor-pointer transition-colors tracking-tight ${
isFeatured ? 'text-base md:text-lg' : 'text-sm md:text-base'
}`}
style={{ fontFamily: 'Poppins, sans-serif' }}>
{q.title}
</h3>
<p className="text-[13px] text-slate-650 mb-4 leading-relaxed line-clamp-2" style={{ fontFamily: 'Inter, sans-serif' }}>
{q.body}
</p>
<div className="flex items-center justify-between flex-wrap gap-3 pt-1.5 border-t border-slate-100/60">
<div className="flex flex-wrap gap-1.5">
{(q.tags || []).map((tag: string) => (
<span
key={tag}
className="px-2 py-0.5 rounded-md text-[10px] font-medium bg-slate-50 text-slate-500 border border-slate-100/60 hover:bg-slate-100/60 hover:text-slate-700 transition-colors cursor-pointer"
style={{ fontFamily: 'Inter, sans-serif' }}
>
#{tag}
</span>
))}
</div>
<div className="flex items-center gap-2 text-[10px] text-slate-400 font-bold">
<button
onClick={() => toggleAnswers(q.id)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg transition-all duration-200 cursor-pointer border border-slate-200/30 ${
expandedQuestionIds.has(q.id.toString())
? 'bg-slate-900 text-white border-slate-900'
: 'bg-slate-50 hover:bg-slate-100 text-slate-500 hover:text-slate-700'
}`}
>
<MessageCircle className="w-3.5 h-3.5" />
<span>{getAnswersCount(q)} answers</span>
</button>
{q.isAnswered && (
<span className="bg-emerald-50 text-emerald-700 border border-emerald-200/40 text-[9px] px-1.5 py-0.5 rounded font-semibold tracking-wide uppercase"> Answered</span>
)}
{/* Like/Vote Button inside bottom bar */}
<button
onClick={() => toggleLike(q.id)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg transition-all duration-200 cursor-pointer border ${
likedIds.has(q.id.toString())
? 'bg-rose-50 text-rose-600 border-rose-100/60 hover:bg-rose-100/60'
: 'bg-slate-50 hover:bg-slate-100 text-slate-500 hover:text-slate-700 border-slate-200/30'
}`}
>
<ThumbsUp className="w-3 h-3" />
<span>{getVotesCount(q) + (likedIds.has(q.id.toString()) ? 1 : 0)}</span>
</button>
</div>
</div>
{/* Expandable Answers Section */}
{expandedQuestionIds.has(q.id.toString()) && (
<div className="mt-4 pt-4 border-t border-slate-100 flex flex-col gap-3.5 pl-3 border-l-2 border-l-slate-100">
<div className="flex items-center justify-between">
<h4 className="text-[11px] font-bold text-slate-700 tracking-tight uppercase" style={{ fontFamily: 'Poppins, sans-serif' }}>
Answers ({getAnswersCount(q)})
</h4>
</div>
{Array.isArray(q.answers) && q.answers.length > 0 ? (
<div className="flex flex-col gap-2.5 max-h-60 overflow-y-auto pr-1">
{q.answers.map((ans: any) => (
<div key={ans.id} className="bg-slate-50/50 rounded-xl p-3.5 border border-slate-100 hover:bg-slate-50/80 transition-colors">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="text-xs font-bold text-slate-700" style={{ fontFamily: 'Poppins, sans-serif' }}>{ans.author}</span>
<span className="text-[9px] text-slate-300"></span>
<span className="text-[10px] text-slate-400 font-medium" style={{ fontFamily: 'Inter, sans-serif' }}>{getRelativeTime(ans.createdAt)}</span>
</div>
<p className="text-xs text-slate-600 leading-relaxed" style={{ fontFamily: 'Inter, sans-serif' }}>
{ans.body}
</p>
</div>
))}
</div>
) : (
<p className="text-xs text-slate-400 italic font-medium py-1" style={{ fontFamily: 'Inter, sans-serif' }}>
No answers posted yet. Senior helpers can reply to this question via the Telegram Bot!
</p>
)}
</div>
)}
</div>
</div>
</motion.div>
</StaggerItem>
);
})}
</StaggerContainer>
)}
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6 px-1">
<motion.button
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.95 }}
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 0 || loading}
className={`flex items-center gap-1.5 px-4 py-2 rounded-xl border text-[12px] font-semibold transition-all duration-200 cursor-pointer ${
currentPage === 0 || loading
? 'border-slate-100 bg-slate-50 text-slate-300 cursor-not-allowed'
: 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50 hover:border-slate-300 shadow-sm'
}`}
style={{ fontFamily: 'Poppins, sans-serif' }}
>
<ChevronRight className="w-4 h-4 rotate-180" />
Prev
</motion.button>
<div className="flex items-center gap-1.5">
{Array.from({ length: totalPages }).map((_, i) => (
<motion.button
key={i}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => handlePageChange(i)}
disabled={loading}
className={`w-7 h-7 rounded-lg text-[11px] font-bold transition-all duration-200 cursor-pointer border ${
i === currentPage
? 'bg-slate-900 text-white border-slate-900'
: 'bg-white text-slate-500 border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}`}
style={{ fontFamily: 'Poppins, sans-serif' }}
>
{i + 1}
</motion.button>
))}
</div>
<motion.button
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.95 }}
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages - 1 || loading}
className={`flex items-center gap-1.5 px-4 py-2 rounded-xl border text-[12px] font-semibold transition-all duration-200 cursor-pointer ${
currentPage >= totalPages - 1 || loading
? 'border-slate-100 bg-slate-50 text-slate-300 cursor-not-allowed'
: 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50 hover:border-slate-300 shadow-sm'
}`}
style={{ fontFamily: 'Poppins, sans-serif' }}
>
Next
<ChevronRight className="w-4 h-4" />
</motion.button>
</div>
)}
</div>
</div>
{/* Sidebar */}
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-6">
{/* Trending */}
<AnimatedContainer direction="right" delay={0.1}>
<div className="bg-white rounded-2xl border border-[#E5E7EB] p-5" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
<div className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs">
<div className="flex items-center gap-2 mb-4">
<TrendingUp className="w-4.5 h-4.5 text-[#F97316]" />
<h3 className="text-sm font-semibold text-[#1E293B]" style={{ fontFamily: 'Poppins, sans-serif' }}>Trending Topics</h3>
<TrendingUp className="w-4 h-4 text-slate-600" />
<h3 className="text-sm font-semibold text-slate-850 tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>Trending Topics</h3>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
{TRENDING_TAGS.map((tag) => (
<button
key={tag}
onClick={() => setSearchQuery(tag)}
className="px-3 py-1.5 rounded-full text-xs font-medium bg-[#FFF7ED] text-[#F97316] hover:bg-[#F97316] hover:text-white transition-all border border-[#FED7AA]"
className="px-2.5 py-1.5 rounded-lg text-[11px] font-medium bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900 transition-all border-0 cursor-pointer"
style={{ fontFamily: 'Inter, sans-serif' }}
>
#{tag}
@@ -224,15 +559,15 @@ export default function Community() {
{/* Stats */}
<AnimatedContainer direction="right" delay={0.2}>
<div className="bg-white rounded-2xl border border-[#E5E7EB] p-5" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
<div className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs">
{[
{ label: 'Total Questions', value: '124' },
{ label: 'Answered', value: '98%' },
{ label: 'Active Students', value: '340+' },
].map((stat, i) => (
<div key={i} className={`flex justify-between py-2.5 ${i < 2 ? 'border-b border-[#E5E7EB]' : ''}`}>
<span className="text-xs text-[#94A3B8]" style={{ fontFamily: 'Inter, sans-serif' }}>{stat.label}</span>
<span className="text-xs font-bold text-[#F97316]" style={{ fontFamily: 'Poppins, sans-serif' }}>{stat.value}</span>
<div key={i} className={`flex justify-between py-2.5 ${i < 2 ? 'border-b border-slate-100' : ''}`}>
<span className="text-xs text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>{stat.label}</span>
<span className="text-xs font-bold text-slate-900" style={{ fontFamily: 'Poppins, sans-serif' }}>{stat.value}</span>
</div>
))}
</div>
@@ -246,25 +581,25 @@ export default function Community() {
{activeTab === 'confession' && (
<motion.div
key="confession"
initial={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.3 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.25 }}
>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
{/* Post confession */}
<div
className="rounded-3xl p-6 mb-8 border"
style={{ background: 'linear-gradient(135deg, #1E293B, #334155)', borderColor: 'rgba(255,255,255,0.1)' }}
className="rounded-xl p-5 mb-6 border"
style={{ background: '#0F172A', borderColor: 'rgba(255,255,255,0.05)' }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: 'rgba(249,115,22,0.2)' }}>
<Lock className="w-5 h-5 text-[#F97316]" />
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-slate-800">
<Lock className="w-4.5 h-4.5 text-slate-300" />
</div>
<div>
<h3 className="text-white font-semibold text-sm" style={{ fontFamily: 'Poppins, sans-serif' }}>Share Anonymously</h3>
<p className="text-slate-400 text-xs">Your identity is never revealed</p>
<h3 className="text-white font-semibold text-sm tracking-tight" style={{ fontFamily: 'Poppins, sans-serif' }}>Share Anonymously</h3>
<p className="text-slate-400 text-[11px]">Your identity is never revealed</p>
</div>
</div>
@@ -273,29 +608,26 @@ export default function Community() {
onChange={(e) => setConfessionText(e.target.value)}
placeholder="Share your thoughts, stories, crushes, or anything on your mind... It's completely anonymous 🤫"
rows={4}
className="w-full rounded-xl p-4 text-sm placeholder-slate-500 focus:outline-none resize-none mb-4"
className="w-full rounded-xl p-3 text-[13px] placeholder-slate-500 focus:outline-none resize-none mb-4 bg-white/5 border border-white/10 text-white"
style={{
fontFamily: 'Inter, sans-serif',
backgroundColor: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.1)',
color: 'white',
}}
/>
<div className="flex items-center gap-3 p-3 rounded-xl mb-4" style={{ backgroundColor: 'rgba(249,115,22,0.1)', border: '1px solid rgba(249,115,22,0.2)' }}>
<Shield className="w-4 h-4 text-[#F97316] shrink-0" />
<span className="text-xs text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>
<div className="flex items-center gap-2.5 p-3 rounded-lg mb-4 bg-white/5 border border-white/10">
<Shield className="w-4 h-4 text-slate-300 shrink-0" />
<span className="text-[11px] text-slate-400" style={{ fontFamily: 'Inter, sans-serif' }}>
No IP tracking. No username. 100% anonymous posting.
</span>
</div>
<div className="flex items-center gap-3">
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
onClick={handleConfess}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl text-white text-sm font-semibold"
style={{ fontFamily: 'Poppins, sans-serif', background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl text-white text-[13px] font-semibold bg-[#F97316] hover:bg-[#EA580C] transition-colors cursor-pointer"
style={{ fontFamily: 'Poppins, sans-serif' }}
>
<Smile className="w-4 h-4" />
Post Anonymously
@@ -322,39 +654,41 @@ export default function Community() {
{CONFESSIONS_DATA.map((conf) => (
<StaggerItem key={conf.id}>
<motion.div
whileHover={{ y: -2 }}
className="bg-white rounded-2xl border border-[#E5E7EB] p-5"
style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}
whileHover={{ y: -0.5 }}
className="bg-white rounded-xl border border-slate-200/50 p-5 transition-all duration-300 hover:border-slate-350 hover:shadow-[0_8px_30px_rgba(17,24,39,0.015)]"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl flex items-center justify-center bg-[#FFF7ED]">
<Shield className="w-4 h-4 text-[#F97316]" />
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-slate-50 border border-slate-100">
<Shield className="w-4 h-4 text-slate-500" />
</div>
<div>
<span className="text-xs font-semibold text-[#1E293B]" style={{ fontFamily: 'Poppins, sans-serif' }}>Anonymous</span>
<p className="text-[10px] text-[#94A3B8]">{formatDate(conf.createdAt)}</p>
<span className="text-xs font-bold text-slate-800" style={{ fontFamily: 'Poppins, sans-serif' }}>Anonymous</span>
<p className="text-[10px] text-slate-400 font-medium" style={{ fontFamily: 'Inter, sans-serif' }}>{getRelativeTime(conf.createdAt)}</p>
</div>
</div>
{conf.category && (
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-[#FFF7ED] text-[#F97316] border border-[#FED7AA]" style={{ fontFamily: 'Poppins, sans-serif' }}>
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-slate-55 text-slate-650 border border-slate-200/40" style={{ fontFamily: 'Poppins, sans-serif' }}>
{conf.category}
</span>
)}
</div>
<p className="text-sm text-[#475569] leading-relaxed mb-4" style={{ fontFamily: 'Inter, sans-serif' }}>{conf.content}</p>
<p className="text-[13px] text-slate-750 leading-relaxed mb-4" style={{ fontFamily: 'Inter, sans-serif' }}>{conf.content}</p>
<div className="flex items-center justify-between">
<button
onClick={() => toggleLike(conf.id)}
className="flex items-center gap-2 px-3 py-1.5 rounded-xl transition-all"
style={{ backgroundColor: likedIds.has(conf.id) ? '#FFF7ED' : '#F8FAFC' }}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg transition-all duration-200 cursor-pointer border text-[10px] font-bold ${
likedIds.has(conf.id)
? 'bg-rose-50 text-rose-600 border-rose-100/60 hover:bg-rose-100/60'
: 'bg-slate-50 hover:bg-slate-100 text-slate-500 hover:text-slate-700 border-slate-200/30'
}`}
>
<Heart
className="w-3.5 h-3.5"
style={{ color: likedIds.has(conf.id) ? '#F97316' : '#94A3B8' }}
fill={likedIds.has(conf.id) ? '#F97316' : 'none'}
fill={likedIds.has(conf.id) ? '#e11d48' : 'none'}
color={likedIds.has(conf.id) ? '#e11d48' : 'currentColor'}
/>
<span className="text-xs text-[#94A3B8]">{conf.reactions + (likedIds.has(conf.id) ? 1 : 0)}</span>
<span>{conf.reactions + (likedIds.has(conf.id) ? 1 : 0)}</span>
</button>
</div>
</motion.div>
@@ -365,9 +699,9 @@ export default function Community() {
{/* Sidebar */}
<AnimatedContainer direction="right" delay={0.15}>
<div className="bg-white rounded-2xl border border-[#E5E7EB] p-5" style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}>
<h3 className="text-sm font-semibold text-[#1E293B] mb-4 flex items-center gap-2" style={{ fontFamily: 'Poppins, sans-serif' }}>
<Shield className="w-4 h-4 text-[#F97316]" />
<div className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-xs">
<h3 className="text-sm font-semibold text-slate-850 mb-4 flex items-center gap-2" style={{ fontFamily: 'Poppins, sans-serif' }}>
<Shield className="w-4 h-4 text-slate-500" />
Community Rules
</h3>
{[
@@ -377,9 +711,9 @@ export default function Community() {
'Keep it relevant to campus life',
'Confessions are 100% anonymous',
].map((rule, i) => (
<div key={i} className="flex items-start gap-2.5 py-2.5 border-b border-[#E5E7EB] last:border-0">
<ChevronRight className="w-3.5 h-3.5 text-[#F97316] mt-0.5 shrink-0" />
<span className="text-xs text-[#475569]" style={{ fontFamily: 'Inter, sans-serif' }}>{rule}</span>
<div key={i} className="flex items-start gap-2.5 py-2.5 border-b border-slate-100 last:border-0">
<ChevronRight className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
<span className="text-xs text-slate-650" style={{ fontFamily: 'Inter, sans-serif' }}>{rule}</span>
</div>
))}
</div>

View File

@@ -1,18 +1,16 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Calendar, MapPin, Clock, Users, ChevronLeft, ChevronRight, Tag, ExternalLink } from 'lucide-react';
import {
Users, Tag, X, Mail, Phone, UserCheck, GraduationCap, Info,
Atom, Wifi, Printer, Zap, Languages, Calculator, HeartHandshake,
Rocket, Camera, Sparkles, Mic, Target, ArrowRight, RotateCcw,
MessageCircle, ExternalLink, CheckCircle2, Compass, Globe, BookOpen, Heart
} from 'lucide-react';
import SectionTitle from '@/components/SectionTitle/SectionTitle';
import { StaggerContainer, StaggerItem } from '@/components/AnimatedContainer/AnimatedContainer';
import AnimatedContainer from '@/components/AnimatedContainer/AnimatedContainer';
import { EVENTS_DATA, CLUBS_DATA } from '@/constants';
const CATEGORY_COLORS: Record<string, { bg: string; text: string; border: string }> = {
technical: { bg: '#EFF6FF', text: '#3B82F6', border: '#BFDBFE' },
cultural: { bg: '#FDF2F8', text: '#EC4899', border: '#FBCFE8' },
sports: { bg: '#ECFDF5', text: '#10B981', border: '#A7F3D0' },
seminar: { bg: '#FFF7ED', text: '#F97316', border: '#FED7AA' },
workshop: { bg: '#F5F3FF', text: '#8B5CF6', border: '#DDD6FE' },
};
import { CLUBS_DATA } from '@/constants';
import type { Club } from '@/types';
const CLUB_CATEGORY_COLORS: Record<string, string> = {
Technical: '#3B82F6',
@@ -21,21 +19,173 @@ const CLUB_CATEGORY_COLORS: Record<string, string> = {
Creative: '#F59E0B',
};
const CLUB_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Atom,
Wifi,
Printer,
Zap,
Languages,
Calculator,
HeartHandshake,
Rocket,
Camera,
Sparkles,
Mic,
BookOpen,
Globe,
Users,
};
// ─── Inline Brand SVG Icons for Feature 5 ──────────────────────────────────────
const InstagramIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<rect width="20" height="20" x="2" y="2" rx="5" ry="5" />
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
<line x1="17.5" x2="17.51" y1="6.5" y2="6.5" />
</svg>
);
const LinkedinIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
<rect width="4" height="12" x="2" y="9" />
<circle cx="4" cy="4" r="2" />
</svg>
);
const YoutubeIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-16.2 0A2 2 0 0 1 2.5 17" />
<polygon points="10 15 15 12 10 9 10 15" fill="currentColor" />
</svg>
);
// ─── Quiz Questions Data for Feature 1 (3-Question Matcher) ─────────────────────
const QUIZ_QUESTIONS = [
{
id: 'interest',
title: '1. What topics or domains are you most interested in?',
options: [
{ label: '🚀 Space Tech, Astronomy & Rocketry', clubIds: ['stellar_space_tech', 'infinitus'] },
{ label: '⚙️ AI Quests, Chip Design, RobochipX & STEM', clubIds: ['steam', 'wistem', 'techspark'] },
{ label: '🎙️ Radio, Podcasting, Storytelling & RJing', clubIds: ['podx', 'mediastic'] },
{ label: '🤝 Community Service, Village Development & NSS Drives', clubIds: ['nss', 'unnat_bharat'] },
{ label: '🎭 Dance, Music, Band & Cultural Arts', clubIds: ['artist_league', 'podx'] },
{ label: '🧮 Fast Calculation, PiDoku, Logic & Mathematics', clubIds: ['infinitus', 'stellar_space_tech'] },
{ label: '✍️ Tamil Literature, Debates & Cultural Heritage', clubIds: ['vaarithi', 'fusion'] },
{ label: '🌸 Japanese Culture, Anime, Manga & Foreign Languages', clubIds: ['nippon', 'fusion'] },
{ label: '📷 Photo/Video Production & Social Media Content', clubIds: ['mediastic', 'helios', 'podx'] },
],
},
{
id: 'skills',
title: '2. What skills do you currently have or want to develop?',
options: [
{ label: '💻 Web3, AI Tools, Circuit Design & Coding Hackathons', clubIds: ['wistem', 'steam', 'techspark'] },
{ label: '🌌 Space Science & Physics Problem Solving', clubIds: ['stellar_space_tech', 'infinitus'] },
{ label: '🎙️ Public Speaking, Interviewing & Voice Recording', clubIds: ['podx', 'mediastic'] },
{ label: '🤝 Social Work, Environmental Protection & Leadership', clubIds: ['nss', 'unnat_bharat'] },
{ label: '🕺 Stage Performance, Singing, Dance & Rap', clubIds: ['artist_league'] },
{ label: '📐 Analytical Thinking, Logic Puzzles & Aptitude', clubIds: ['infinitus'] },
{ label: '📝 Essay Writing, Tamil/English Oratory & Literature', clubIds: ['vaarithi', 'fusion'] },
{ label: '🎥 Camera Operations, Video Editing & Digital Media', clubIds: ['mediastic', 'helios'] },
],
},
{
id: 'goal',
title: '3. What is your main goal for joining a club at RIT?',
options: [
{ label: '🤖 Build hackathon projects, chip designs & STEM innovations', clubIds: ['steam', 'wistem', 'techspark'] },
{ label: '🚀 Work on aerospace tech & scientific projects', clubIds: ['stellar_space_tech', 'infinitus'] },
{ label: '🌟 Share inspiring stories & host campus podcasts', clubIds: ['podx', 'mediastic'] },
{ label: '👥 Drive social change & serve rural communities', clubIds: ['nss', 'unnat_bharat'] },
{ label: '🏆 Perform live at cultural fests & stage shows', clubIds: ['artist_league', 'vaarithi'] },
{ label: '📖 Master new languages & explore world cultures', clubIds: ['nippon', 'fusion', 'vaarithi'] },
],
},
];
export default function Events() {
const [currentIdx, setCurrentIdx] = useState(0);
const [selectedCategory, setSelectedCategory] = useState('All');
const [selectedClub, setSelectedClub] = useState<Club | null>(null);
const categories = ['All', 'Technical', 'Cultural', 'Seminar', 'Workshop'];
// ─── Interactive Like / Favorite System State ─────────────────────────────
const [likedClubs, setLikedClubs] = useState<Set<string>>(() => {
try {
const saved = localStorage.getItem('rit_freshers_liked_clubs');
return saved ? new Set(JSON.parse(saved)) : new Set(['podx', 'stellar_space_tech']);
} catch {
return new Set(['podx', 'stellar_space_tech']);
}
});
const filteredEvents = EVENTS_DATA.filter((e) =>
selectedCategory === 'All' || e.category === selectedCategory.toLowerCase()
);
const [likesMap, setLikesMap] = useState<Record<string, number>>(() => {
const initialMap: Record<string, number> = {};
CLUBS_DATA.forEach((c, idx) => {
initialMap[c.id] = Math.round(c.members * 0.42) + (idx % 5) * 14 + 35;
});
return initialMap;
});
const prev = () => setCurrentIdx((i) => (i === 0 ? EVENTS_DATA.length - 1 : i - 1));
const next = () => setCurrentIdx((i) => (i === EVENTS_DATA.length - 1 ? 0 : i + 1));
const toggleLike = (clubId: string) => {
setLikedClubs((prev) => {
const next = new Set(prev);
const isCurrentlyLiked = next.has(clubId);
if (isCurrentlyLiked) {
next.delete(clubId);
setLikesMap((l) => ({ ...l, [clubId]: Math.max(0, (l[clubId] || 1) - 1) }));
} else {
next.add(clubId);
setLikesMap((l) => ({ ...l, [clubId]: (l[clubId] || 0) + 1 }));
}
try {
localStorage.setItem('rit_freshers_liked_clubs', JSON.stringify(Array.from(next)));
} catch {}
return next;
});
};
const featured = EVENTS_DATA[currentIdx];
const featCfg = CATEGORY_COLORS[featured.category] || CATEGORY_COLORS.seminar;
// ─── Club Matcher Quiz State (Feature 1) ─────────────────────────────────
const [isQuizOpen, setIsQuizOpen] = useState(false);
const [quizStep, setQuizStep] = useState(0);
const [selectedAnswers, setSelectedAnswers] = useState<number[]>([]);
const [quizResults, setQuizResults] = useState<{ club: Club; score: number }[] | null>(null);
const handleSelectOption = (optionIdx: number) => {
const updated = [...selectedAnswers];
updated[quizStep] = optionIdx;
setSelectedAnswers(updated);
if (quizStep < QUIZ_QUESTIONS.length - 1) {
setQuizStep(quizStep + 1);
} else {
calculateQuizResults(updated);
}
};
const calculateQuizResults = (answers: number[]) => {
const scoreMap: Record<string, number> = {};
CLUBS_DATA.forEach((c) => (scoreMap[c.id] = 0));
answers.forEach((ansIdx, qIdx) => {
const option = QUIZ_QUESTIONS[qIdx].options[ansIdx];
option.clubIds.forEach((clubId, idx) => {
scoreMap[clubId] = (scoreMap[clubId] || 0) + (3 - idx);
});
});
const ranked = CLUBS_DATA.map((club) => ({
club,
score: scoreMap[club.id] || 0,
})).sort((a, b) => b.score - a.score);
setQuizResults(ranked.slice(0, 3));
};
const resetQuiz = () => {
setQuizStep(0);
setSelectedAnswers([]);
setQuizResults(null);
};
return (
<div className="min-h-screen" style={{ backgroundColor: '#FAFAFA' }}>
@@ -43,221 +193,537 @@ export default function Events() {
<div className="bg-white border-b border-[#E5E7EB] py-10">
<div className="container-custom">
<h1 className="text-3xl md:text-4xl font-bold text-[#1E293B] mb-2" style={{ fontFamily: 'Playfair Display, serif' }}>
Clubs &{' '}
Student{' '}
<span style={{ background: 'linear-gradient(135deg, #F97316, #FB923C)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
Events
Clubs
</span>
</h1>
<p className="text-[#475569]" style={{ fontFamily: 'Inter, sans-serif' }}>
Discover events, join clubs, and be part of the RIT community.
Explore official RIT student clubs & societies, leadership details, and community links.
</p>
</div>
</div>
<div className="container-custom py-10">
{/* Featured Event Carousel */}
<SectionTitle tag="Upcoming" title="Featured" highlight="Events" />
<div className="container-custom pt-10 pb-20 md:pb-28">
<AnimatedContainer className="relative mb-14">
<div className="bg-white rounded-3xl border border-[#E5E7EB] overflow-hidden" style={{ boxShadow: '0 8px 40px -8px rgba(0,0,0,0.1)' }}>
<AnimatePresence mode="wait">
<motion.div
key={featured.id}
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -30 }}
transition={{ duration: 0.3 }}
className="flex flex-col md:flex-row"
>
{/* Image placeholder */}
<div
className="w-full md:w-2/5 h-56 md:h-auto flex items-center justify-center"
style={{ background: 'linear-gradient(135deg, #1E293B, #334155)' }}
>
<div className="text-center">
<div className="text-5xl mb-2">
{featured.category === 'technical' ? '💻' : featured.category === 'cultural' ? '🎭' : featured.category === 'seminar' ? '🎤' : '📅'}
</div>
<div className="text-white/60 text-sm">{featured.venue}</div>
</div>
</div>
{/* ─── Feature 1: "Find My Ideal Club" Banner ────────────────────────── */}
<AnimatedContainer className="mb-14">
<div
className="rounded-3xl p-6 md:p-8 text-white relative overflow-hidden border border-orange-400/30 flex flex-col md:flex-row items-center justify-between gap-6"
style={{ background: 'linear-gradient(135deg, #1E293B, #0F172A)' }}
>
{/* Background Glow */}
<div
className="absolute top-0 right-0 w-80 h-80 rounded-full opacity-20 pointer-events-none"
style={{ background: 'radial-gradient(circle, #F97316, transparent)' }}
/>
{/* Content */}
<div className="flex-1 p-8">
<div className="flex items-center gap-2 mb-3">
<span
className="px-3 py-1 rounded-full text-xs font-semibold capitalize"
style={{ backgroundColor: featCfg.bg, color: featCfg.text, border: `1px solid ${featCfg.border}`, fontFamily: 'Poppins, sans-serif' }}
>
{featured.category}
</span>
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-emerald-50 text-emerald-600 border border-emerald-200" style={{ fontFamily: 'Poppins, sans-serif' }}>
Upcoming
</span>
</div>
<div className="relative z-10 max-w-xl">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-orange-500/20 text-orange-400 text-xs font-semibold mb-3 border border-orange-500/30">
<Target className="w-3.5 h-3.5" />
<span>Interactive Club Matcher</span>
</div>
<h2 className="text-2xl md:text-3xl font-bold text-white mb-2" style={{ fontFamily: 'Playfair Display, serif' }}>
Not sure which club to join?
</h2>
<p className="text-slate-300 text-sm leading-relaxed" style={{ fontFamily: 'Inter, sans-serif' }}>
Take our 3-step AI-powered Club Matcher quiz to get instant recommendations tailored to your interests, skills, and goals!
</p>
</div>
<h3 className="text-2xl font-bold text-[#1E293B] mb-3" style={{ fontFamily: 'Playfair Display, serif' }}>
{featured.title}
</h3>
<p className="text-[#475569] text-sm leading-relaxed mb-5" style={{ fontFamily: 'Inter, sans-serif' }}>
{featured.description}
</p>
<div className="grid grid-cols-2 gap-3 mb-6">
{[
{ icon: Calendar, label: new Date(featured.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' }) },
{ icon: Clock, label: featured.time },
{ icon: MapPin, label: featured.venue },
{ icon: Users, label: featured.organizer },
].map((item, i) => (
<div key={i} className="flex items-center gap-2 text-sm text-[#475569]">
<item.icon className="w-4 h-4 text-[#F97316] shrink-0" />
<span style={{ fontFamily: 'Inter, sans-serif' }}>{item.label}</span>
</div>
))}
</div>
<div className="flex items-center gap-3">
<motion.button
whileHover={{ scale: 1.03 }}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-white text-sm font-semibold"
style={{ fontFamily: 'Poppins, sans-serif', background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
>
Register Now
<ExternalLink className="w-4 h-4" />
</motion.button>
<motion.button
whileHover={{ scale: 1.03 }}
className="px-5 py-2.5 rounded-xl text-sm font-semibold border-2 border-[#E5E7EB] text-[#475569] hover:border-[#F97316] hover:text-[#F97316] transition-all"
style={{ fontFamily: 'Poppins, sans-serif' }}
>
More Details
</motion.button>
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
{/* Carousel Controls */}
<div className="flex items-center justify-center gap-3 mt-4">
<button onClick={prev} className="w-9 h-9 rounded-xl border border-[#E5E7EB] bg-white flex items-center justify-center text-[#475569] hover:border-[#F97316] hover:text-[#F97316] transition-all">
<ChevronLeft className="w-4 h-4" />
</button>
{EVENTS_DATA.map((_, i) => (
<button
key={i}
onClick={() => setCurrentIdx(i)}
className="transition-all rounded-full"
style={{ width: i === currentIdx ? '24px' : '8px', height: '8px', backgroundColor: i === currentIdx ? '#F97316' : '#E5E7EB' }}
/>
))}
<button onClick={next} className="w-9 h-9 rounded-xl border border-[#E5E7EB] bg-white flex items-center justify-center text-[#475569] hover:border-[#F97316] hover:text-[#F97316] transition-all">
<ChevronRight className="w-4 h-4" />
</button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.98 }}
onClick={() => { resetQuiz(); setIsQuizOpen(true); }}
className="relative z-10 px-6 py-3.5 rounded-2xl text-white font-semibold text-sm flex items-center gap-2.5 shadow-lg shrink-0 cursor-pointer"
style={{ background: 'linear-gradient(135deg, #F97316, #FB923C)', fontFamily: 'Poppins, sans-serif' }}
>
<Sparkles className="w-4 h-4" />
Find My Ideal Club
<ArrowRight className="w-4 h-4" />
</motion.button>
</div>
</AnimatedContainer>
{/* All Events Grid */}
<div className="mb-14">
<div className="flex items-center justify-between mb-6 flex-wrap gap-4">
<h2 className="text-xl font-bold text-[#1E293B]" style={{ fontFamily: 'Playfair Display, serif' }}>All Events</h2>
<div className="flex gap-2 flex-wrap">
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className="px-3.5 py-1.5 rounded-xl text-xs font-medium transition-all"
style={{
fontFamily: 'Poppins, sans-serif',
backgroundColor: selectedCategory === cat ? '#F97316' : '#F8FAFC',
color: selectedCategory === cat ? 'white' : '#475569',
border: `1px solid ${selectedCategory === cat ? '#F97316' : '#E5E7EB'}`,
}}
>
{cat}
</button>
))}
</div>
</div>
{/* Clubs Directory Section */}
<SectionTitle tag="Official Directory" title="Student" highlight="Clubs" subtitle="Explore official RIT clubs & societies. Click any club to view full details, leadership, and community social links." />
<StaggerContainer className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
{filteredEvents.map((event) => {
const cfg = CATEGORY_COLORS[event.category] || CATEGORY_COLORS.seminar;
return (
<StaggerItem key={event.id}>
<motion.div
whileHover={{ y: -4 }}
className="bg-white rounded-2xl border border-[#E5E7EB] p-5 flex gap-4"
style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}
>
<div className="w-14 h-14 rounded-xl flex flex-col items-center justify-center shrink-0" style={{ backgroundColor: cfg.bg }}>
<span className="text-xs font-bold" style={{ color: cfg.text, fontFamily: 'Poppins, sans-serif' }}>
{new Date(event.date).toLocaleDateString('en-IN', { day: 'numeric' })}
</span>
<span className="text-[10px]" style={{ color: cfg.text }}>
{new Date(event.date).toLocaleDateString('en-IN', { month: 'short' })}
</span>
</div>
<div className="flex-1">
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold capitalize" style={{ backgroundColor: cfg.bg, color: cfg.text, fontFamily: 'Poppins, sans-serif' }}>
{event.category}
</span>
<h3 className="text-sm font-semibold text-[#1E293B] mt-1.5 mb-1" style={{ fontFamily: 'Poppins, sans-serif' }}>{event.title}</h3>
<div className="flex items-center gap-3 text-xs text-[#94A3B8]">
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{event.time}</span>
<span className="flex items-center gap-1"><MapPin className="w-3 h-3" />{event.venue}</span>
</div>
</div>
</motion.div>
</StaggerItem>
);
})}
</StaggerContainer>
</div>
{(() => {
const fullGridCount = Math.floor(CLUBS_DATA.length / 3) * 3;
const mainGridClubs = CLUBS_DATA.slice(0, fullGridCount);
const remainingClubs = CLUBS_DATA.slice(fullGridCount);
{/* Clubs Section */}
<SectionTitle tag="Join a Club" title="Student" highlight="Clubs" subtitle="Explore clubs and societies at RIT. Find your passion and connect with like-minded peers." />
<StaggerContainer className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{CLUBS_DATA.map((club) => (
<StaggerItem key={club.id}>
const renderClubCard = (club: Club) => {
const IconComponent = (club.icon && CLUB_ICON_MAP[club.icon]) || Atom;
const categoryColor = CLUB_CATEGORY_COLORS[club.category] || '#F97316';
const isLiked = likedClubs.has(club.id);
const likesCount = likesMap[club.id] || 0;
return (
<motion.div
whileHover={{ y: -4 }}
className="bg-white rounded-2xl border border-[#E5E7EB] p-5"
onClick={() => setSelectedClub(club)}
className="bg-white rounded-2xl border border-[#E5E7EB] p-5 cursor-pointer hover:border-[#F97316] transition-all flex flex-col justify-between h-full group"
style={{ boxShadow: '0 2px 15px -3px rgba(0,0,0,0.07)' }}
>
<div className="flex items-start justify-between mb-3">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center text-white font-bold text-lg"
style={{ background: `linear-gradient(135deg, ${CLUB_CATEGORY_COLORS[club.category] || '#F97316'}, ${CLUB_CATEGORY_COLORS[club.category] || '#F97316'}CC)`, fontFamily: 'Poppins, sans-serif' }}
>
{club.name[0]}
</div>
<div className="flex items-center gap-1 text-xs text-[#94A3B8]">
<Users className="w-3.5 h-3.5" />
<span>{club.members} members</span>
<div>
{/* Top Row: Tech Icon / Club Logo & Interactive Heart Like Button */}
<div className="flex items-center justify-between mb-3">
{club.logoUrl ? (
<div className="w-12 h-12 rounded-full border border-slate-100 p-0.5 shadow-sm bg-white overflow-hidden shrink-0 transition-transform group-hover:scale-105">
<img src={club.logoUrl} alt={club.name} className="w-full h-full object-cover rounded-full" />
</div>
) : (
<div
className="w-11 h-11 rounded-full flex items-center justify-center text-white shadow-sm transition-transform group-hover:scale-105"
style={{
background: `linear-gradient(135deg, ${categoryColor}, ${categoryColor}DD)`,
}}
>
<IconComponent className="w-5 h-5 text-white" />
</div>
)}
{/* Like Button & Members Count */}
<div className="flex items-center gap-2">
<motion.button
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.85 }}
onClick={(e) => {
e.stopPropagation();
toggleLike(club.id);
}}
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold transition-all cursor-pointer border ${
isLiked
? 'bg-rose-50 border-rose-200 text-rose-600 shadow-2xs'
: 'bg-slate-50 border-slate-200 text-slate-500 hover:border-rose-300 hover:text-rose-500'
}`}
>
<Heart className={`w-3.5 h-3.5 ${isLiked ? 'fill-rose-500 text-rose-500' : ''}`} />
<span>{likesCount}</span>
</motion.button>
<div className="flex items-center gap-1 text-xs text-[#94A3B8] bg-slate-50 px-2 py-1 rounded-full border border-slate-100">
<Users className="w-3.5 h-3.5 text-slate-400" />
<span>{club.members}</span>
</div>
</div>
</div>
{/* Club Title & Description */}
<h3 className="font-semibold text-[#1E293B] mb-1 group-hover:text-[#F97316] transition-colors" style={{ fontFamily: 'Poppins, sans-serif' }}>
{club.name}
</h3>
<p className="text-xs text-[#64748B] mb-3 line-clamp-2" style={{ fontFamily: 'Inter, sans-serif' }}>{club.description}</p>
</div>
<h3 className="font-semibold text-[#1E293B] mb-1" style={{ fontFamily: 'Poppins, sans-serif' }}>{club.name}</h3>
<p className="text-xs text-[#94A3B8] mb-3" style={{ fontFamily: 'Inter, sans-serif' }}>{club.description}</p>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1 text-xs" style={{ color: CLUB_CATEGORY_COLORS[club.category] || '#F97316' }}>
{/* Bottom Row: Category Tag & View Details Button */}
<div className="flex items-center justify-between mt-auto pt-2 border-t border-slate-100">
<span className="flex items-center gap-1 text-xs font-medium" style={{ color: categoryColor }}>
<Tag className="w-3 h-3" />
{club.category}
</span>
<motion.button
whileHover={{ scale: 1.04 }}
className="px-4 py-1.5 rounded-lg text-xs font-semibold text-white"
className="px-3.5 py-1.5 rounded-lg text-xs font-semibold text-white flex items-center gap-1 cursor-pointer"
style={{ fontFamily: 'Poppins, sans-serif', background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
>
Join Club
View Details
</motion.button>
</div>
</motion.div>
</StaggerItem>
))}
</StaggerContainer>
);
};
return (
<StaggerContainer className="pb-8 space-y-6 md:space-y-7">
{/* 3-Column Grid for Full Rows */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-7">
{mainGridClubs.map((club) => (
<StaggerItem key={club.id} className="w-full h-full">
{renderClubCard(club)}
</StaggerItem>
))}
</div>
{/* Centered Flex Row for Remaining Clubs (WiSTEM & STEAM) */}
{remainingClubs.length > 0 && (
<div className="flex flex-wrap justify-center gap-6 md:gap-7">
{remainingClubs.map((club) => (
<StaggerItem key={club.id} className="w-full sm:w-[calc(50%-0.875rem)] lg:w-[calc(33.333%-1.167rem)]">
{renderClubCard(club)}
</StaggerItem>
))}
</div>
)}
</StaggerContainer>
);
})()}
</div>
{/* ─── Club Matcher Quiz Modal (Feature 1) ─────────────────────────────── */}
<AnimatePresence>
{isQuizOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-3xl border border-[#E5E7EB] max-w-xl w-full p-6 md:p-8 relative shadow-2xl overflow-hidden"
>
{/* Close Button */}
<button
onClick={() => setIsQuizOpen(false)}
className="absolute top-5 right-5 w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center text-slate-500 hover:bg-slate-200 transition-all cursor-pointer"
>
<X className="w-5 h-5" />
</button>
{!quizResults ? (
<div>
{/* Quiz Progress */}
<div className="flex items-center justify-between text-xs text-slate-500 mb-2 font-medium">
<span>Question {quizStep + 1} of {QUIZ_QUESTIONS.length}</span>
<span>Step {quizStep + 1} / {QUIZ_QUESTIONS.length}</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full mb-6 overflow-hidden">
<div
className="h-full bg-[#F97316] transition-all duration-300 rounded-full"
style={{ width: `${((quizStep + 1) / QUIZ_QUESTIONS.length) * 100}%` }}
/>
</div>
{/* Question Header */}
<div className="flex items-center gap-2 mb-2 text-[#F97316]">
<Compass className="w-5 h-5" />
<span className="text-xs font-bold uppercase tracking-wider">Club Matcher Quiz</span>
</div>
<h3 className="text-xl font-bold text-[#1E293B] mb-5" style={{ fontFamily: 'Playfair Display, serif' }}>
{QUIZ_QUESTIONS[quizStep].title}
</h3>
{/* Options List */}
<div className="space-y-3 mb-6">
{QUIZ_QUESTIONS[quizStep].options.map((opt, idx) => (
<button
key={idx}
onClick={() => handleSelectOption(idx)}
className="w-full text-left p-4 rounded-2xl border border-slate-200 hover:border-[#F97316] hover:bg-orange-50/50 transition-all flex items-center justify-between text-sm font-medium text-slate-700 cursor-pointer group"
>
<span>{opt.label}</span>
<ArrowRight className="w-4 h-4 text-slate-300 group-hover:text-[#F97316] group-hover:translate-x-1 transition-all" />
</button>
))}
</div>
{quizStep > 0 && (
<button
onClick={() => setQuizStep(quizStep - 1)}
className="text-xs font-semibold text-slate-500 hover:text-slate-800 flex items-center gap-1 cursor-pointer"
>
Previous Question
</button>
)}
</div>
) : (
/* Results Screen */
<div>
<div className="text-center mb-6">
<div className="w-14 h-14 rounded-2xl bg-orange-100 text-[#F97316] flex items-center justify-center mx-auto mb-3">
<CheckCircle2 className="w-8 h-8" />
</div>
<h3 className="text-2xl font-bold text-[#1E293B]" style={{ fontFamily: 'Playfair Display, serif' }}>
Your Ideal Club Matches!
</h3>
<p className="text-xs text-slate-500 mt-1">Based on your interests & goals, here are your top recommended RIT clubs:</p>
</div>
{/* Top 3 Matches */}
<div className="space-y-3 mb-6">
{quizResults.map((item, i) => {
const IconComponent = (item.club.icon && CLUB_ICON_MAP[item.club.icon]) || Atom;
const catColor = CLUB_CATEGORY_COLORS[item.club.category] || '#F97316';
const matchPercent = i === 0 ? '98%' : i === 1 ? '91%' : '84%';
return (
<div
key={item.club.id}
onClick={() => {
setIsQuizOpen(false);
setSelectedClub(item.club);
}}
className="p-4 rounded-2xl border border-slate-200 hover:border-[#F97316] bg-slate-50 hover:bg-white cursor-pointer transition-all flex items-center justify-between"
>
<div className="flex items-center gap-3">
{item.club.logoUrl ? (
<div className="w-10 h-10 rounded-full border border-slate-100 p-0.5 shadow-sm bg-white overflow-hidden shrink-0">
<img src={item.club.logoUrl} alt={item.club.name} className="w-full h-full object-cover rounded-full" />
</div>
) : (
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white shrink-0"
style={{ background: `linear-gradient(135deg, ${catColor}, ${catColor}DD)` }}
>
<IconComponent className="w-5 h-5 text-white" />
</div>
)}
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-[#1E293B]">{item.club.name}</span>
{i === 0 && <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-100 text-amber-700">Top Match 🏆</span>}
</div>
<span className="text-xs text-slate-500">{item.club.category} Club {item.club.members} members</span>
</div>
</div>
<span className="px-3 py-1 rounded-full text-xs font-bold bg-emerald-50 text-emerald-600 border border-emerald-200 shrink-0">
{matchPercent} Match
</span>
</div>
);
})}
</div>
<div className="flex gap-3">
<button
onClick={resetQuiz}
className="flex-1 py-3 rounded-xl border border-slate-300 text-slate-600 font-semibold text-xs flex items-center justify-center gap-1.5 hover:bg-slate-50 transition-all cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
Retake Quiz
</button>
<button
onClick={() => setIsQuizOpen(false)}
className="flex-1 py-3 rounded-xl text-white font-semibold text-xs bg-[#F97316] hover:bg-[#EA580C] transition-all cursor-pointer"
>
Explore All Clubs
</button>
</div>
</div>
)}
</motion.div>
</div>
)}
</AnimatePresence>
{/* ─── Club Details Modal (Featuring Feature 5: Social Link Hub) ───────── */}
<AnimatePresence>
{selectedClub && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-3xl border border-[#E5E7EB] max-w-xl w-full max-h-[90vh] overflow-y-auto p-6 md:p-8 relative"
style={{ boxShadow: '0 20px 50px rgba(0,0,0,0.2)' }}
>
{/* Close Button */}
<button
onClick={() => setSelectedClub(null)}
className="absolute top-5 right-5 w-9 h-9 rounded-full bg-slate-100 flex items-center justify-center text-slate-500 hover:bg-slate-200 transition-all cursor-pointer"
>
<X className="w-5 h-5" />
</button>
{/* Modal Header */}
{(() => {
const IconComponent = (selectedClub.icon && CLUB_ICON_MAP[selectedClub.icon]) || Atom;
const categoryColor = CLUB_CATEGORY_COLORS[selectedClub.category] || '#F97316';
const isLiked = likedClubs.has(selectedClub.id);
const likesCount = likesMap[selectedClub.id] || 0;
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-5 pb-4 border-b border-slate-100">
<div className="flex items-center gap-4">
{selectedClub.logoUrl ? (
<div className="w-16 h-16 rounded-full border-2 border-slate-100 p-1 shadow-md bg-white overflow-hidden shrink-0">
<img src={selectedClub.logoUrl} alt={selectedClub.name} className="w-full h-full object-cover rounded-full" />
</div>
) : (
<div
className="w-14 h-14 rounded-full flex items-center justify-center text-white font-bold text-2xl shadow-sm shrink-0"
style={{ background: `linear-gradient(135deg, ${categoryColor}, ${categoryColor}DD)` }}
>
<IconComponent className="w-7 h-7 text-white" />
</div>
)}
<div>
<span
className="px-2.5 py-0.5 rounded-full text-xs font-semibold"
style={{ backgroundColor: `${categoryColor}15`, color: categoryColor, fontFamily: 'Poppins, sans-serif' }}
>
{selectedClub.category}
</span>
<h2 className="text-2xl font-bold text-[#1E293B] mt-1" style={{ fontFamily: 'Playfair Display, serif' }}>
{selectedClub.name}
</h2>
</div>
</div>
{/* Interactive Like Button in Modal */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.92 }}
onClick={() => toggleLike(selectedClub.id)}
className={`px-4 py-2 rounded-xl text-xs font-bold flex items-center gap-2 transition-all cursor-pointer shadow-xs border shrink-0 ${
isLiked
? 'bg-rose-500 text-white border-rose-600 shadow-rose-200'
: 'bg-rose-50 text-rose-600 border-rose-200 hover:bg-rose-100'
}`}
>
<Heart className={`w-4 h-4 ${isLiked ? 'fill-white' : 'text-rose-500'}`} />
<span>{isLiked ? 'Liked' : 'Like Club'} ({likesCount})</span>
</motion.button>
</div>
);
})()}
{/* Club Detailed Description */}
<div className="mb-5 bg-gradient-to-r from-orange-50/70 via-amber-50/40 to-slate-50 border border-orange-200/80 rounded-2xl p-4.5">
<div className="flex items-center gap-2 text-xs font-bold text-[#F97316] uppercase tracking-wider mb-2">
<Info className="w-4 h-4 text-[#F97316]" />
<span>About the Club</span>
</div>
<p className="text-slate-700 text-sm leading-relaxed" style={{ fontFamily: 'Inter, sans-serif' }}>
{selectedClub.details || selectedClub.description}
</p>
</div>
{/* Leadership & Contact Information - Icon Based Color Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3.5 mb-6">
{/* President Card - Indigo Theme */}
<div className="bg-indigo-50/80 border border-indigo-100 hover:border-indigo-300 transition-colors rounded-2xl p-3.5 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-indigo-600 text-white flex items-center justify-center shadow-xs shadow-indigo-200 shrink-0">
<UserCheck className="w-5 h-5 text-white" />
</div>
<div>
<span className="text-[11px] text-indigo-500 font-semibold block">President / Student Lead</span>
<span className="text-sm font-bold text-indigo-950">{selectedClub.presidentName || 'Student President'}</span>
</div>
</div>
{/* Year Card - Purple Theme */}
<div className="bg-purple-50/80 border border-purple-100 hover:border-purple-300 transition-colors rounded-2xl p-3.5 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 text-white flex items-center justify-center shadow-xs shadow-purple-200 shrink-0">
<GraduationCap className="w-5 h-5 text-white" />
</div>
<div>
<span className="text-[11px] text-purple-500 font-semibold block">Year & Department</span>
<span className="text-sm font-bold text-purple-950">{selectedClub.year || 'Senior Year'}</span>
</div>
</div>
{/* Email Card - Emerald Theme */}
<div className="bg-emerald-50/80 border border-emerald-100 hover:border-emerald-300 transition-colors rounded-2xl p-3.5 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 text-white flex items-center justify-center shadow-xs shadow-emerald-200 shrink-0">
<Mail className="w-5 h-5 text-white" />
</div>
<div className="min-w-0 flex-1">
<span className="text-[11px] text-emerald-500 font-semibold block">Contact Email</span>
<a href={`mailto:${selectedClub.contactEmail}`} className="text-sm font-bold text-emerald-700 hover:text-emerald-800 hover:underline truncate block">
{selectedClub.contactEmail || 'club@ritchennai.edu.in'}
</a>
</div>
</div>
{/* Phone Card - Sky Theme */}
<div className="bg-sky-50/80 border border-sky-100 hover:border-sky-300 transition-colors rounded-2xl p-3.5 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-sky-500 to-sky-600 text-white flex items-center justify-center shadow-xs shadow-sky-200 shrink-0">
<Phone className="w-5 h-5 text-white" />
</div>
<div>
<span className="text-[11px] text-sky-500 font-semibold block">Contact Phone</span>
<span className="text-sm font-bold text-sky-950">{selectedClub.contactPhone || '+91 98765 43210'}</span>
</div>
</div>
</div>
{/* ─── Feature 5: Social & Community Link Hub (Dummy Links) ─────── */}
<div className="mb-6 bg-orange-50/60 border border-orange-200/80 rounded-2xl p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 text-xs font-bold text-[#F97316] uppercase tracking-wider">
<ExternalLink className="w-3.5 h-3.5" />
<span>Community & Social Links</span>
</div>
<span className="text-[10px] text-emerald-600 font-medium"> Verified RIT Handle</span>
</div>
<div className="grid grid-cols-2 gap-2.5">
{selectedClub.socialLinks?.instagram && (
<a
href={selectedClub.socialLinks.instagram}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-2.5 rounded-xl bg-white border border-slate-200 hover:border-pink-500 hover:text-pink-600 transition-all text-xs font-medium text-slate-700"
>
<InstagramIcon className="w-4 h-4 text-pink-500 shrink-0" />
<span className="truncate">Instagram</span>
</a>
)}
{selectedClub.socialLinks?.website && (
<a
href={selectedClub.socialLinks.website}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-2.5 rounded-xl bg-white border border-slate-200 hover:border-orange-500 hover:text-orange-600 transition-all text-xs font-medium text-slate-700"
>
<Globe className="w-4 h-4 text-orange-500 shrink-0" />
<span className="truncate">Linktree Hub</span>
</a>
)}
{selectedClub.socialLinks?.linkedin && (
<a
href={selectedClub.socialLinks.linkedin}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-2.5 rounded-xl bg-white border border-slate-200 hover:border-blue-600 hover:text-blue-600 transition-all text-xs font-medium text-slate-700"
>
<LinkedinIcon className="w-4 h-4 text-blue-600 shrink-0" />
<span className="truncate">LinkedIn</span>
</a>
)}
{selectedClub.socialLinks?.whatsapp && (
<a
href={selectedClub.socialLinks.whatsapp}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-2.5 rounded-xl bg-white border border-slate-200 hover:border-emerald-500 hover:text-emerald-600 transition-all text-xs font-medium text-slate-700"
>
<MessageCircle className="w-4 h-4 text-emerald-500 shrink-0" />
<span className="truncate">WhatsApp Group</span>
</a>
)}
{selectedClub.socialLinks?.youtube && (
<a
href={selectedClub.socialLinks.youtube}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-2.5 rounded-xl bg-white border border-slate-200 hover:border-red-600 hover:text-red-600 transition-all text-xs font-medium text-slate-700"
>
<YoutubeIcon className="w-4 h-4 text-red-600 shrink-0" />
<span className="truncate">YouTube Channel</span>
</a>
)}
</div>
</div>
{/* Close Button */}
<div>
<button
onClick={() => setSelectedClub(null)}
className="w-full py-3 rounded-xl border border-slate-300 text-slate-700 font-semibold text-sm hover:bg-slate-100 transition-all cursor-pointer"
style={{ fontFamily: 'Poppins, sans-serif' }}
>
Close
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -227,11 +227,11 @@ export default function Home() {
{ icon: 'Map', label: 'Campus Context', desc: 'RIT-specific answers' },
{ icon: 'Zap', label: 'Instant Answers', desc: 'No wait time' },
].map((item, i) => {
const Icon = (LucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[item.icon];
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>>)[item.icon];
return (
<div key={i} className="flex items-start gap-3 p-4 rounded-xl border border-[#E5E7EB] bg-[#FAFAFA]">
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-[#FFF7ED] shrink-0">
{Icon && <Icon className="w-4.5 h-4.5" style={{ color: '#F97316' } as React.CSSProperties} />}
{Icon && <Icon className="w-4.5 h-4.5" style={{ color: '#F97316' }} />}
</div>
<div>
<div className="text-sm font-semibold text-[#1E293B]" style={{ fontFamily: 'Poppins, sans-serif' }}>{item.label}</div>
@@ -331,7 +331,7 @@ export default function Home() {
{/* Quick nav buttons */}
<div className="p-5 grid grid-cols-4 sm:grid-cols-8 gap-3">
{CAMPUS_LOCATIONS.map((loc) => {
const Icon = (LucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[loc.icon];
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[loc.icon];
return (
<Link to="/campus" key={loc.id}>
<motion.div

View File

@@ -142,7 +142,7 @@ export default function Notes() {
>
<div className="flex items-start justify-between gap-2">
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: typeConfig.bg }}>
<TypeIcon className="w-5 h-5" style={{ color: typeConfig.text } as React.CSSProperties} />
<TypeIcon className="w-5 h-5 text-orange-500" />
</div>
<span
className="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase"

View File

@@ -95,14 +95,31 @@ export interface Event {
}
// ─── Clubs ───────────────────────────────────────────────────────────────────
export interface ClubSocialLinks {
instagram?: string;
linkedin?: string;
whatsapp?: string;
youtube?: string;
github?: string;
website?: string;
}
export interface Club {
id: string;
name: string;
description: string;
details?: string;
category: string;
members: number;
logo?: string;
presidentName?: string;
year?: string;
contactEmail?: string;
contactPhone?: string;
logo?: string;
logoUrl?: string;
icon?: string;
defaultRank?: number;
socialLinks?: ClubSocialLinks;
}
// ─── Community / Q&A ─────────────────────────────────────────────────────────

50
telegram-bot/README.md Normal file
View File

@@ -0,0 +1,50 @@
# 🤖 RIT Freshers Hub - Telegram Q&A Intermediary Bot
This is a lightweight Python microservice that acts as an intermediary between the student Q&A forum and registered helper accounts (Seniors/Staff) on Telegram.
---
## 🚀 How It Works
1. **Student submits a question** on the web portal.
2. **Spring Boot Backend** saves the question and triggers an HTTP POST request to the Python bot: `/send_question`.
3. **Telegram Bot** broadcasts the question with a `force_reply` prompt to all configured helper accounts.
4. **Helpers reply** directly to the Telegram message.
5. **Telegram Bot** captures the reply, maps it back to the original question ID, and pushes the answer back to the Spring Boot REST endpoint (`POST /api/questions/{id}/answers`).
---
## 🛠️ Installation & Setup
### 1. Install Dependencies
Run this in the `telegram-bot/` directory:
```bash
pip install -r requirements.txt
```
### 2. Configure the Bot
Update [config.json](config.json):
```json
{
"telegram_bot_token": "YOUR_TELEGRAM_BOT_TOKEN",
"helper_chat_ids": [
971749136
],
"spring_backend_url": "http://localhost:8080"
}
```
#### How to find your Telegram Chat ID:
1. Search for the bot username on Telegram and click **Start** (or send `/start`).
2. The bot will automatically reply with your exact **Chat ID**.
3. Add this ID to the `helper_chat_ids` array in `config.json`. The bot dynamically reloads configuration changes on the fly!
---
## 🏃 Running the Bot
Start the server:
```bash
python telegram_bot.py
```
* **Bot Server Port**: `8082`
* **Local Mappings Store**: `bot_mappings.db` (SQLite) is created automatically to persist message mappings so that replies continue to map to the correct questions even if the bot is restarted.

Binary file not shown.

7
telegram-bot/config.json Normal file
View File

@@ -0,0 +1,7 @@
{
"telegram_bot_token": "",
"helper_chat_ids": [971749136,5567776672],
"discord_bot_token": "",
"discord_helper_user_ids": [789393727641878568],
"spring_backend_url": "http://localhost:8080"
}

View File

@@ -0,0 +1,5 @@
requests>=2.28.0
fastapi>=0.95.0
uvicorn>=0.20.0
pydantic>=2.0
discord.py>=2.0.0

View File

@@ -0,0 +1,138 @@
import requests
import json
import re
import os
from urllib.parse import urljoin
from bs4 import BeautifulSoup
def scrape_routes():
base_url = "https://www.rittransport.com/"
index_url = urljoin(base_url, "js/51jan26.php")
print(f"Fetching index from {index_url}...")
res = requests.get(index_url)
res.encoding = 'utf-8'
soup = BeautifulSoup(res.text, 'html.parser')
routes = []
# Find table
table = soup.find('table')
if not table:
print("No table found on index page.")
return
rows = table.find_all('tr')
for row in rows:
cells = row.find_all('td')
if len(cells) < 4:
continue
sno = cells[0].get_text(strip=True)
rno = cells[1].get_text(strip=True)
rname = cells[2].get_text(strip=True)
# Link is in cells[3] (Timing column)
link_tag = cells[3].find('a')
if not link_tag or not link_tag.get('href'):
continue
href = link_tag.get('href')
detail_url = urljoin("https://www.rittransport.com/js/", href.strip())
start_time = cells[4].get_text(strip=True)
routes.append({
"number": rno,
"name": rname,
"detail_url": detail_url,
"start_time": start_time
})
print(f"Found {len(routes)} routes. Fetching details...")
detailed_routes = []
# Colors for frontend UI
colors = [
'#F97316', '#3B82F6', '#10B981', '#8B5CF6', '#EF4444',
'#EC4899', '#06B6D4', '#F59E0B', '#14B8A6', '#6366F1'
]
for i, r in enumerate(routes):
try:
detail_res = requests.get(r['detail_url'])
detail_res.encoding = 'utf-8'
html_content = detail_res.text
dsoup = BeautifulSoup(html_content, 'html.parser')
stops = []
detail_rows = dsoup.find_all('tr')
for drow in detail_rows:
tds = drow.find_all('td')
if len(tds) >= 2:
stop_name = tds[0].get_text(strip=True)
stop_time = tds[1].get_text(strip=True)
stop_name = re.sub(r'\s+', ' ', stop_name).strip()
stop_time = re.sub(r'\s+', ' ', stop_time).strip()
if stop_name and not stop_name.startswith("R-") and not "Boarding" in stop_name:
# Normalize time format
stop_time = stop_time.replace('.', ':')
if ':' in stop_time:
parts = stop_time.split()
time_part = parts[0]
ampm_part = parts[1].upper() if len(parts) > 1 else "AM"
h_m = time_part.split(':')
if len(h_m) == 2:
h, m = h_m[0].strip(), h_m[1].strip()
h = "".join(filter(str.isdigit, h))
m = "".join(filter(str.isdigit, m))
if h and m:
h_int = int(h)
h_str = f"{h_int:02d}"
m_str = f"{int(m):02d}"
stop_time = f"{h_str}:{m_str} {ampm_part}"
stops.append({
"name": stop_name,
"time": stop_time
})
if len(stops) > 0:
from_point = stops[0]['name']
to_point = stops[-1]['name']
departure_time = stops[0]['time']
arrival_time = stops[-1]['time']
else:
from_point = r['name']
to_point = "RIT Campus"
departure_time = r['start_time']
arrival_time = "07:40 AM"
detailed_routes.append({
"number": r['number'],
"name": r['name'] + " Route",
"from": from_point,
"to": to_point,
"departureTime": departure_time,
"arrivalTime": arrival_time,
"color": colors[i % len(colors)],
"stops": stops
})
except Exception as e:
print(f"Error scraping {r['number']}: {e}")
# Save directly to the backend resources folder
target_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "backend", "src", "main", "resources"))
if not os.path.exists(target_dir):
os.makedirs(target_dir, exist_ok=True)
output_path = os.path.join(target_dir, "bus_routes.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(detailed_routes, f, indent=2)
print(f"Scraping completed. Saved to {output_path}.")
if __name__ == "__main__":
scrape_routes()

View File

@@ -0,0 +1,362 @@
import os
import json
import sqlite3
import threading
import time
import logging
import requests
import asyncio
import discord
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import uvicorn
# Configure Logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler()
]
)
# Load env variables from .env if present
env_path = os.path.join(os.path.dirname(__file__), ".env")
if os.path.exists(env_path):
with open(env_path, "r", encoding="utf-8") as env_file:
for line in env_file:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, val = line.split("=", 1)
os.environ[key.strip()] = val.strip()
# Load Configuration
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
def load_config():
if not os.path.exists(CONFIG_PATH):
default_config = {
"telegram_bot_token": "",
"helper_chat_ids": [],
"discord_bot_token": "",
"discord_helper_user_ids": [],
"spring_backend_url": "http://localhost:8080"
}
with open(CONFIG_PATH, "w") as f:
json.dump(default_config, f, indent=2)
return default_config
with open(CONFIG_PATH, "r") as f:
return json.load(f)
config = load_config()
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN") or config.get("telegram_bot_token")
DISCORD_TOKEN = os.environ.get("DISCORD_BOT_TOKEN") or config.get("discord_bot_token")
BACKEND_URL = os.environ.get("SPRING_BACKEND_URL") or config.get("spring_backend_url", "http://localhost:8080")
# Database Setup
DB_PATH = os.path.join(os.path.dirname(__file__), "bot_mappings.db")
def init_db():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS question_mappings (
chat_id INTEGER,
message_id INTEGER,
question_id INTEGER,
PRIMARY KEY (chat_id, message_id)
)
""")
conn.commit()
conn.close()
init_db()
def save_mapping(chat_id: int, message_id: int, question_id: int):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO question_mappings (chat_id, message_id, question_id) VALUES (?, ?, ?)",
(chat_id, message_id, question_id)
)
conn.commit()
conn.close()
def get_question_id(chat_id: int, message_id: int) -> int:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"SELECT question_id FROM question_mappings WHERE chat_id = ? AND message_id = ?",
(chat_id, message_id)
)
row = cursor.fetchone()
conn.close()
return row[0] if row else None
# Telegram API Helpers
def send_telegram_message(chat_id: int, text: str, reply_to_message_id: int = None) -> dict:
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown",
"reply_markup": {"force_reply": True, "selective": True}
}
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
try:
response = requests.post(url, json=payload, timeout=10)
return response.json()
except Exception as e:
logging.error(f"Error sending Telegram message to {chat_id}: {e}")
return {}
# Background Long Polling for Telegram Updates
def telegram_polling_thread():
logging.info("Starting Telegram long polling thread...")
offset = 0
while True:
# Re-load config dynamic updates
current_config = load_config()
helpers = current_config.get("helper_chat_ids", [])
backend_url = os.environ.get("SPRING_BACKEND_URL") or current_config.get("spring_backend_url")
bot_token = os.environ.get("TELEGRAM_BOT_TOKEN") or current_config.get("telegram_bot_token")
url = f"https://api.telegram.org/bot{bot_token}/getUpdates"
params = {"offset": offset, "timeout": 20}
try:
response = requests.get(url, params=params, timeout=25)
data = response.json()
if not data.get("ok"):
logging.error(f"Telegram API getUpdates error: {data}")
time.sleep(5)
continue
updates = data.get("result", [])
for update in updates:
offset = update["update_id"] + 1
message = update.get("message")
if not message:
continue
chat_id = message["chat"]["id"]
text = message.get("text", "").strip()
# Help helper find their Chat ID
if text == "/start":
welcome_text = (
f"👋 *Welcome to RIT Freshers Hub Intermediary Bot!*\n\n"
f"To configure this helper, register this Chat ID in the `config.json` file:\n"
f"`{chat_id}`\n\n"
f"Once registered, you will receive new student questions here and can reply directly to them."
)
send_telegram_message(chat_id, welcome_text)
continue
# Process reply messages
reply_to = message.get("reply_to_message")
if reply_to:
# Verify if helper is authorized
if chat_id not in helpers:
logging.warning(f"Unauthorized message from chat ID {chat_id}")
send_telegram_message(chat_id, "⚠️ You are not registered as an authorized helper in config.json.")
continue
original_message_id = reply_to["message_id"]
question_id = get_question_id(chat_id, original_message_id)
if question_id:
# Extract author name
first_name = message["from"].get("first_name", "")
last_name = message["from"].get("last_name", "")
author_name = f"{first_name} {last_name}".strip() or "Senior Helper"
logging.info(f"Submitting answer for question {question_id} by helper '{author_name}'")
# Post answer to Spring Boot backend
backend_endpoint = f"{backend_url}/api/questions/{question_id}/answers"
answer_payload = {
"body": text,
"author": author_name
}
try:
res = requests.post(backend_endpoint, json=answer_payload, timeout=10)
if res.status_code == 200 or res.status_code == 201:
send_telegram_message(chat_id, "✅ *Answer posted successfully to the Q&A board!*", reply_to_message_id=message["message_id"])
else:
send_telegram_message(chat_id, f"❌ *Failed to post answer to backend.* (Status: {res.status_code})\nResponse: {res.text[:100]}", reply_to_message_id=message["message_id"])
except Exception as e:
logging.error(f"Error calling backend endpoint {backend_endpoint}: {e}")
send_telegram_message(chat_id, f"❌ *Connection error to backend.* ({e})", reply_to_message_id=message["message_id"])
else:
send_telegram_message(chat_id, "❓ This message does not correspond to any active question or the mapping has expired.", reply_to_message_id=message["message_id"])
except Exception as e:
logging.error(f"Error in long polling loop: {e}")
time.sleep(5)
# Start Polling Thread
polling_thread = threading.Thread(target=telegram_polling_thread, daemon=True)
polling_thread.start()
# Discord Bot Client Setup
intents = discord.Intents.default()
intents.messages = True
intents.message_content = True
discord_client = discord.Client(intents=intents)
discord_loop = None
@discord_client.event
async def on_ready():
logging.info(f"Discord Bot logged in as {discord_client.user}!")
@discord_client.event
async def on_message(message):
if message.author == discord_client.user:
return
# Process DMs
if isinstance(message.channel, discord.DMChannel):
current_config = load_config()
discord_helpers = current_config.get("discord_helper_user_ids", [])
author_id = message.author.id
# Verify helper is authorized
if author_id not in [int(x) for x in discord_helpers if str(x).isdigit()]:
logging.warning(f"Unauthorized Discord message from user ID {author_id}")
await message.channel.send("⚠️ You are not registered as an authorized helper in config.json.")
return
# Check if helper is replying to a specific question message
if message.reference and message.reference.message_id:
original_message_id = message.reference.message_id
question_id = get_question_id(author_id, original_message_id)
if question_id:
author_name = message.author.name
logging.info(f"Submitting Discord answer for question {question_id} by helper '{author_name}'")
# Post answer to Spring Boot backend
backend_url = os.environ.get("SPRING_BACKEND_URL") or current_config.get("spring_backend_url")
backend_endpoint = f"{backend_url}/api/questions/{question_id}/answers"
answer_payload = {
"body": message.content,
"author": author_name
}
try:
res = requests.post(backend_endpoint, json=answer_payload, timeout=10)
if res.status_code in [200, 201]:
await message.reply("✅ *Answer posted successfully to the Q&A board!*")
else:
await message.reply(f"❌ *Failed to post answer to backend.* (Status: {res.status_code})")
except Exception as e:
logging.error(f"Error calling backend endpoint {backend_endpoint}: {e}")
await message.reply(f"❌ *Connection error to backend.* ({e})")
else:
await message.reply("❓ This message does not correspond to any active question or the mapping has expired.")
else:
await message.reply("💬 Please use the **Reply** feature on the question message to answer it so I know which question you're answering!")
async def broadcast_discord_question(question_id: int, title: str, body: str, author: str, user_ids: list):
formatted_msg = (
f"❓ **New Student Question!**\n\n"
f"👤 **Author:** {author}\n"
f"📌 **Topic:** {title}\n"
f"📝 **Details:** {body}\n\n"
f"💬 **Reply directly to this message to submit your answer.**"
)
for user_id_val in user_ids:
try:
user_id = int(user_id_val)
user = await discord_client.fetch_user(user_id)
if user:
msg = await user.send(formatted_msg)
save_mapping(user_id, msg.id, question_id)
logging.info(f"Sent Discord DM to helper {user_id}")
except Exception as e:
logging.error(f"Failed to send Discord DM to helper {user_id_val}: {e}")
def run_discord_bot():
global discord_loop
logging.info("Starting Discord bot thread...")
discord_loop = asyncio.new_event_loop()
asyncio.set_event_loop(discord_loop)
try:
discord_loop.run_until_complete(discord_client.start(DISCORD_TOKEN))
except Exception as e:
logging.error(f"Discord Bot failed to run: {e}")
# Start Discord Bot Thread
discord_thread = threading.Thread(target=run_discord_bot, daemon=True)
discord_thread.start()
# FastAPI Web Server Setup
app = FastAPI(title="RIT Telegram & Discord Intermediary Bot HTTP Server")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class QuestionPayload(BaseModel):
question_id: int
title: str
body: str
author: str
@app.post("/send_question")
def send_question(payload: QuestionPayload):
current_config = load_config()
# 1. Telegram Broadcast
telegram_helpers = current_config.get("helper_chat_ids", [])
telegram_sent = 0
if telegram_helpers:
logging.info(f"Broadcasting question {payload.question_id} to {len(telegram_helpers)} Telegram helpers.")
formatted_msg = (
f"❓ *New Student Question!*\n\n"
f"👤 *Author:* {payload.author}\n"
f"📌 *Topic:* {payload.title}\n"
f"📝 *Details:* {payload.body}\n\n"
f"💬 *Reply to this message directly to submit your answer.*"
)
for chat_id in telegram_helpers:
res = send_telegram_message(chat_id, formatted_msg)
if res.get("ok"):
message_id = res["result"]["message_id"]
save_mapping(chat_id, message_id, payload.question_id)
telegram_sent += 1
# 2. Discord Broadcast
discord_helpers = current_config.get("discord_helper_user_ids", [])
discord_sent = 0
if discord_helpers and DISCORD_TOKEN:
logging.info(f"Broadcasting question {payload.question_id} to {len(discord_helpers)} Discord helpers.")
if discord_loop:
try:
asyncio.run_coroutine_threadsafe(
broadcast_discord_question(payload.question_id, payload.title, payload.body, payload.author, discord_helpers),
discord_loop
)
discord_sent = len(discord_helpers)
except Exception as e:
logging.error(f"Error scheduling Discord broadcast: {e}")
else:
logging.warning("Discord loop not running. Skipping Discord broadcast.")
return {
"status": "success",
"telegram_delivered_to": telegram_sent,
"discord_queued_for": discord_sent
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8082)

View File

@@ -17,6 +17,7 @@
"jsx": "react-jsx",
/* Path aliases */
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]

View File

@@ -9,6 +9,15 @@ export default defineConfig({
react(),
tailwindcss(),
],
server: {
watch: {
ignored: [
'**/backend/**',
'**/telegram-bot/**',
'**/.git/**'
]
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),