diff --git a/api/main_endpoints/models/Visit.js b/api/main_endpoints/models/Visit.js new file mode 100644 index 000000000..b23643e42 --- /dev/null +++ b/api/main_endpoints/models/Visit.js @@ -0,0 +1,19 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const VisitSchema = new Schema( + { + visitCount: { + type: Number, + default: 0 + }, + type: { + type: String, + enum: ['HOME_PAGE'], + default: 'HOME_PAGE' + } + }, + { collection: 'Visit' } +); + +module.exports = mongoose.model('Visit', VisitSchema); diff --git a/api/main_endpoints/routes/Visit.js b/api/main_endpoints/routes/Visit.js new file mode 100644 index 000000000..425131aee --- /dev/null +++ b/api/main_endpoints/routes/Visit.js @@ -0,0 +1,63 @@ +const express = require('express'); +const router = express.Router(); +const Visit = require('../models/Visit'); +const { + NOT_FOUND, + FORBIDDEN, + BAD_REQUEST, + OK, + INTERNAL_SERVER_ERROR +} = require('../../util/constants').STATUS_CODES; +const logger = require('../../util/logger'); + +const ALLOWED_ORIGIN = 'https://sce.sjsu.edu'; +const VALID_TYPES = Visit.schema.path('type').enumValues; + +router.post('/:type', async (req, res) => { + const { type } = req.params; + + if (process.env.NODE_ENV === 'production') { + const origin = req.get('origin'); + const referer = req.get('referer'); + const isAllowed = origin === ALLOWED_ORIGIN || (referer && referer.startsWith(ALLOWED_ORIGIN)); + + if (!isAllowed) return res.sendStatus(FORBIDDEN); + } + + if (!VALID_TYPES.includes(type)) { + return res.status(NOT_FOUND).send({ message: `Invalid page type: ${type}` }); + } + + try { + await Visit.findOneAndUpdate( + { type }, + { $inc: { visitCount: 1 } }, + { + new: true, + upsert: true, + setDefaultsOnInsert: true, + } + ); + + return res.sendStatus(OK); + } catch (error) { + logger.error(`Error incrementing visit for ${type}:`, error); + return res.sendStatus(INTERNAL_SERVER_ERROR); + } +}); + +router.get('/:type', async (req, res) => { + const { type } = req.params; + + if (!VALID_TYPES.includes(type)) return res.sendStatus(NOT_FOUND); + + try { + const record = await Visit.findOne({ type }); + const visitCount = record ? record.visitCount : 0; + return res.status(OK).send({ visitCount }); + } catch (error) { + return res.sendStatus(BAD_REQUEST); + } +}); + +module.exports = router; diff --git a/src/APIFunctions/Visit.js b/src/APIFunctions/Visit.js new file mode 100644 index 000000000..d931ca0e0 --- /dev/null +++ b/src/APIFunctions/Visit.js @@ -0,0 +1,42 @@ +import { ApiResponse } from './ApiResponses'; +import { BASE_API_URL } from '../Enums'; + +export async function incrementVisitCount(type) { + let status = new ApiResponse(); + try { + const url = new URL(`api/Visit/${type}`, BASE_API_URL); + await fetch (url.href, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.ok) { + status.responseData = result; + } else { + status.error = true; + } + } catch (err) { + status.responseData = err; + status.error = true; + } + return status; +} + +export async function getVisitCount(type) { + let status = new ApiResponse(); + try { + const url = new URL(`api/Visit/${type}`, BASE_API_URL); + const res = await fetch (url.href); + if (res.ok) { + const result = await res.json(); + status.responseData = result; + } else { + status.error = true; + } + } catch (err) { + status.responseData = err; + status.error = true; + } + return status; +} diff --git a/src/Pages/Home/Home.js b/src/Pages/Home/Home.js index d58a79f8c..eef28e1f4 100755 --- a/src/Pages/Home/Home.js +++ b/src/Pages/Home/Home.js @@ -1,126 +1,123 @@ -import React, { useEffect, useState} from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; // Use Link for internal navigation import Footer from '../../Components/Footer/Footer.js'; -import './Home.css'; - import { getAd } from '../../APIFunctions/Advertisement.js'; import { getHomeImage } from '../../APIFunctions/Image.js'; +import { incrementVisitCount, getVisitCount } from '../../APIFunctions/Visit.js'; +import './Home.css'; -const Home = () => { +const DEFAULT_IMAGE = 'https://raw.githubusercontent.com/thebeninator/Clark/refs/heads/add_comp_homepage/public/images/compressed2.jpg'; - const [message, setMessage] = useState(''); - const [showMessage, setShowMessage] = useState(false); - const [showAll, setShowAll] = useState(false); - const [homeImageUrl, setHomeImageUrl] = useState(''); - const [homeImageAlt, setHomeImageAlt] = useState(''); - - async function getMessage() { - try { - const messageData = await getAd(); - const adsList = messageData.responseData; - setMessage(adsList['message']); - setShowMessage(true); - } catch { - setMessage(''); - } - } - - async function loadHomeImage() { - const DEFAULT_IMAGE = 'https://raw.githubusercontent.com/thebeninator/Clark/refs/heads/add_comp_homepage/public/images/compressed2.jpg'; - const response = await getHomeImage(); - let url = DEFAULT_IMAGE; - let alt = 'sce club image'; - if (!response.error) { - url = response.responseData.url; - alt = response.responseData.alt; - } - setHomeImageUrl(url); - setHomeImageAlt(alt); - } +const Home = () => { + const [isLoaded, setIsLoaded] = useState(false); + const [data, setData] = useState({ + message: '', + imageUrl: DEFAULT_IMAGE, + imageAlt: 'SCE Club Image', + visitCount: 0, + }); useEffect(() => { - getMessage(); - loadHomeImage(); - setTimeout(() => setShowAll(true), 100); + const fetchData = async () => { + try { + const [adRes, imgRes, _] = await Promise.all([ + getAd(), + getHomeImage(), + incrementVisitCount('HOME_PAGE'), + ]); + + const visitRes = await getVisitCount('HOME_PAGE'); + + setData({ + message: adRes.responseData?.message || '', + imageUrl: imgRes.error ? DEFAULT_IMAGE : imgRes.responseData.url, + imageAlt: imgRes.error ? 'SCE Club Image' : imgRes.responseData.alt, + visitCount: visitRes.responseData?.visitCount || 0, + }); + } catch (err) { + } finally { + setIsLoaded(true); + } + }; + + fetchData(); }, []); - function isValidUrl(str) { - try { - new URL(str); - return true; - } catch (_) { - return false; - } - } - - function renderMessageWithLinks(message) { - if (!message) { - return null; - } - return message.split(/(https?:\/\/[^\s]+)/g).map((part, index) => { - if (isValidUrl(part)) { + const renderedMessage = useMemo(() => { + if (!data.message) return null; + + return data.message.split(/(https?:\/\/[^\s]+)/g).map((part, i) => { + const isUrl = /https?:\/\/[^\s]+/.test(part); + if (isUrl) { return ( - + {part} ); } - return {part}; + return {part}; }); - } + }, [data.message]); + + // only include values passed in that evaluate to true + const getClassName = (...classes) => classes.filter(Boolean).join(' '); return ( -