From 49ceea53835d1690685769269731663d3ec0a600 Mon Sep 17 00:00:00 2001 From: blaze Date: Sat, 3 Jan 2026 21:51:19 -0500 Subject: [PATCH 1/2] rating sytem! --- README.md | 11 ++ backend/.gitignore | 3 +- backend/data/.gitkeep | 0 backend/index.js | 130 +++++++++++++- site/src/components/PrinterCard.jsx | 25 ++- site/src/components/RatingStars.jsx | 34 ++++ site/src/pages/Leaderboard.jsx | 4 +- site/src/pages/PrintersPage.jsx | 252 +++++++++++++++++++++++++++- 8 files changed, 446 insertions(+), 13 deletions(-) create mode 100644 backend/data/.gitkeep create mode 100644 site/src/components/RatingStars.jsx diff --git a/README.md b/README.md index 787a5ae..c371b28 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,14 @@ New printing legion setup. Fill out forms.hackclub.com/print-legion-signup to sign up! +## Environment + +Create a `.env` file inside `backend/` with the Airtable credentials plus a table for ratings: + +- `AIRTABLE_KEY` +- `AIRTABLE_BASE_ID` +- `AIRTABLE_TABLE_ID` – main printers table (already in use) +- `AIRTABLE_PRINT_TABLE_ID` – print stats table (already in use) +- `AIRTABLE_RATINGS_TABLE_ID` – **new** table where each record represents a single rating with `slack_id` (text), `rating` (number), and `rater_slack_id` (text) to tie submissions back to a Hack Clubber. + +Every time someone submits a rating from the site, the backend will append a record to the ratings table and recompute averages directly from Airtable. diff --git a/backend/.gitignore b/backend/.gitignore index 13dfa36..d296706 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,3 @@ .env -node_modules/ \ No newline at end of file +node_modules/ +package-lock.json \ No newline at end of file diff --git a/backend/data/.gitkeep b/backend/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/index.js b/backend/index.js index 041b6e3..225ebac 100644 --- a/backend/index.js +++ b/backend/index.js @@ -7,12 +7,82 @@ dotenv.config(); const app = express(); const port = 3000; -// 1. Configure Airtable +if (!process.env.AIRTABLE_KEY || !process.env.AIRTABLE_BASE_ID) { + throw new Error("Both AIRTABLE_KEY and AIRTABLE_BASE_ID must be defined."); +} + const base = new Airtable({ apiKey: process.env.AIRTABLE_KEY }).base( process.env.AIRTABLE_BASE_ID ); app.use(cors()); +app.use(express.json()); + +const ratingsTableId = process.env.AIRTABLE_RATINGS_TABLE_ID; +const ratingsTable = ratingsTableId ? base(ratingsTableId) : null; + +const escapeFormulaValue = (value) => (value || "").replace(/'/g, "\\'"); + +const aggregateSummaries = (records) => + records.reduce((acc, record) => { + const slackId = record.get("slack_id"); + const ratingValue = Number(record.get("rating")); + if (!slackId || !Number.isFinite(ratingValue)) return acc; + + if (!acc[slackId]) { + acc[slackId] = { sum: 0, count: 0 }; + } + + acc[slackId].sum += ratingValue; + acc[slackId].count += 1; + return acc; + }, {}); + +const summarizeAggregates = (aggregateMap) => { + const summary = {}; + Object.entries(aggregateMap).forEach(([slackId, stats]) => { + const count = stats.count || 0; + summary[slackId] = { + average: count ? stats.sum / count : 0, + count, + }; + }); + return summary; +}; + +const hasRecentAirtableRating = async (slackId, raterSlackId) => { + if (!ratingsTable) return false; + const filterByFormula = `AND({slack_id}='${escapeFormulaValue( + slackId + )}', {rater_slack_id}='${escapeFormulaValue( + raterSlackId + )}', IS_AFTER(CREATED_TIME(), DATEADD(NOW(), -1, 'day'))) `; + + const records = await ratingsTable + .select({ + fields: ["slack_id"], + filterByFormula, + maxRecords: 1, + }) + .all(); + return records.length > 0; +}; + +async function fetchRatingsSummary(targetSlackId) { + if (!ratingsTable) return {}; + + const selectConfig = { + fields: ["slack_id", "rating"], + }; + + if (targetSlackId) { + selectConfig.filterByFormula = `({slack_id} = '${escapeFormulaValue(targetSlackId)}')`; + } + + const records = await ratingsTable.select(selectConfig).all(); + const aggregate = aggregateSummaries(records); + return summarizeAggregates(aggregate); +} app.get("/api/stats/alltime", async (req, res) => { let total_weight = 0; @@ -93,6 +163,64 @@ app.get("/api/printers", async (req, res) => { } }); +app.get("/api/printers/ratings", async (req, res) => { + if (!ratingsTable) { + return res.status(500).json({ error: "Ratings table is not configured." }); + } + + try { + const summary = await fetchRatingsSummary(); + res.json(summary); + } catch (error) { + console.error("Error loading ratings:", error); + res.status(500).json({ error: "Failed to load ratings" }); + } +}); + +app.post("/api/printers/:slackId/rate", async (req, res) => { + const { slackId } = req.params; + const { rating, rater_slack_id: raterSlackId } = req.body || {}; + const numericRating = Number(rating); + + if (!Number.isFinite(numericRating) || numericRating < 1 || numericRating > 5) { + return res + .status(400) + .json({ error: "Rating must be a number between 1 and 5." }); + } + + if (!raterSlackId || typeof raterSlackId !== "string") { + return res.status(400).json({ error: "Please include your Slack ID." }); + } + + if (!ratingsTable) { + return res.status(500).json({ error: "Ratings table is not configured." }); + } + + try { + if (await hasRecentAirtableRating(slackId, raterSlackId.trim())) { + return res + .status(429) + .json({ error: "You already rated this printer in the last 24 hours." }); + } + + await ratingsTable.create([ + { + fields: { + slack_id: slackId, + rating: numericRating, + rater_slack_id: raterSlackId, + }, + }, + ]); + + const summary = await fetchRatingsSummary(slackId); + res.json({ slack_id: slackId, summary: summary[slackId] || { average: 0, count: 0 } }); + } catch (error) { + console.error("Error saving rating:", error); + res.status(500).json({ error: "Failed to save rating" }); + } +}); + // Serve the frontend from the site/dist directory app.use(express.static(path.resolve(__dirname, "../site/dist"))); app.get("/{*any}", (req, res, next) => { diff --git a/site/src/components/PrinterCard.jsx b/site/src/components/PrinterCard.jsx index 4a33a94..c2ce8be 100644 --- a/site/src/components/PrinterCard.jsx +++ b/site/src/components/PrinterCard.jsx @@ -1,5 +1,9 @@ -export default function PrinterCard({ printer }) { +import RatingStars from "./RatingStars"; + +export default function PrinterCard({ printer, ratingSummary = {}, onSelectRating }) { const { slack_id, nickname, profile_pic, website, bio, country } = printer; + const { average = 0, count = 0, userRating = 0 } = ratingSummary; + const displayValue = userRating || Math.round(average); return (
@@ -43,6 +47,25 @@ export default function PrinterCard({ printer }) { )}
+
+
+ + {count ? average.toFixed(1) : "Unrated"} + + {count > 0 && ( + + ({count} rating{count > 1 ? "s" : ""}) + + )} +
+ onSelectRating?.(printer, value)} + /> +

+ Tap a star to rate this printer. +

+
diff --git a/site/src/components/RatingStars.jsx b/site/src/components/RatingStars.jsx new file mode 100644 index 0000000..a72d234 --- /dev/null +++ b/site/src/components/RatingStars.jsx @@ -0,0 +1,34 @@ +import { useState } from "react"; + +const TOTAL_STARS = 5; + +export default function RatingStars({ currentValue = 0, onSelect }) { + const [hoverValue, setHoverValue] = useState(0); + const displayValue = hoverValue || currentValue; + + return ( +
+ {Array.from({ length: TOTAL_STARS }).map((_, index) => { + const starValue = index + 1; + const isActive = displayValue >= starValue; + return ( + + ); + })} +
+ ); +} diff --git a/site/src/pages/Leaderboard.jsx b/site/src/pages/Leaderboard.jsx index d84b9cd..d4b8989 100644 --- a/site/src/pages/Leaderboard.jsx +++ b/site/src/pages/Leaderboard.jsx @@ -3,8 +3,8 @@ import { useEffect, useMemo, useState } from "react"; const baseApiUrl = (import.meta.env.VITE_API_URL || "/api/").replace(/\/?$/, "/"); const LEADERBOARD_URL = import.meta.env.VITE_LEADERBOARD_URL || - `${baseApiUrl}stats/leaderboard`// || - // "https://printlegion.hackclub.com/api/stats/leaderboard"; + // `${baseApiUrl}stats/leaderboard`// || + "https://printlegion.hackclub.com/api/stats/leaderboard"; export default function Leaderboard() { const [entries, setEntries] = useState([]); diff --git a/site/src/pages/PrintersPage.jsx b/site/src/pages/PrintersPage.jsx index 0f9c69f..5b0d12f 100644 --- a/site/src/pages/PrintersPage.jsx +++ b/site/src/pages/PrintersPage.jsx @@ -2,30 +2,194 @@ import { useState, useEffect } from "react"; import PrinterCard from "../components/PrinterCard"; // Adjust import path const API_URL = import.meta.env.VITE_API_URL || "/api/"; +const RATINGS_ENDPOINT = `${API_URL}printers/ratings`; +const LOCAL_RATINGS_KEY = "printer-user-ratings"; +const RATER_SLACK_KEY = "printer-rater-slack-id"; export default function PrintersPage() { const [printers, setPrinters] = useState([]); const [loading, setLoading] = useState(true); + const [ratings, setRatings] = useState({}); + const [userRatings, setUserRatings] = useState({}); + const [ratingBanner, setRatingBanner] = useState(null); + const [error, setError] = useState(null); + const [raterSlackId, setRaterSlackId] = useState(""); + const [modalState, setModalState] = useState({ open: false, printer: null, rating: 0 }); + const [modalSlackInput, setModalSlackInput] = useState(""); + const [modalMessage, setModalMessage] = useState(null); + const [modalSubmitting, setModalSubmitting] = useState(false); useEffect(() => { - fetch(API_URL + "printers") // Your Express endpoint - .then((res) => res.json()) - .then((data) => { + if (typeof window === "undefined") return; + try { + const storedRatings = window.localStorage.getItem(LOCAL_RATINGS_KEY); + if (storedRatings) { + setUserRatings(JSON.parse(storedRatings)); + } + + const storedSlackId = window.localStorage.getItem(RATER_SLACK_KEY); + if (storedSlackId) { + setRaterSlackId(storedSlackId); + } + } catch (storageError) { + console.warn("Unable to load stored ratings", storageError); + } + }, []); + + useEffect(() => { + async function fetchPrinters() { + try { + setLoading(true); + const response = await fetch(API_URL + "printers"); + if (!response.ok) { + throw new Error(`Failed to fetch printers: ${response.status}`); + } + const data = await response.json(); setPrinters(data); + + try { + const ratingsResponse = await fetch(RATINGS_ENDPOINT); + if (ratingsResponse.ok) { + const ratingsData = await ratingsResponse.json(); + setRatings(ratingsData); + } else { + console.warn("Unable to fetch ratings", ratingsResponse.statusText); + } + } catch (ratingsError) { + console.warn("Ratings fetch failed", ratingsError); + } + } catch (fetchError) { + console.error("Error fetching printers:", fetchError); + setError("We had trouble loading the printers list. Please try again later."); + } finally { setLoading(false); - }) - .catch((error) => { - console.error("Error fetching printers:", error); - setLoading(false); - }); + } + } + + fetchPrinters(); }, []); + useEffect(() => { + if (!ratingBanner) return; + const timeout = setTimeout(() => setRatingBanner(null), 4000); + return () => clearTimeout(timeout); + }, [ratingBanner]); + + const persistUserRatings = (nextRatings) => { + setUserRatings(nextRatings); + if (typeof window !== "undefined") { + try { + window.localStorage.setItem( + LOCAL_RATINGS_KEY, + JSON.stringify(nextRatings) + ); + } catch (storageError) { + console.warn("Unable to store rating locally", storageError); + } + } + }; + + const persistRaterSlackId = (value) => { + setRaterSlackId(value); + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(RATER_SLACK_KEY, value); + } catch (storageError) { + console.warn("Unable to persist slack id", storageError); + } + } + }; + + const submitRating = async (slackId, nickname, rating, slackIdentifier) => { + try { + const response = await fetch(`${API_URL}printers/${slackId}/rate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating, rater_slack_id: slackIdentifier }), + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + const message = payload.error || `Failed to save rating: ${response.status}`; + throw new Error(message); + } + + const data = await response.json(); + setRatings((prev) => ({ + ...prev, + [slackId]: data.summary, + })); + + const nextUserRatings = { + ...userRatings, + [slackId]: rating, + }; + persistUserRatings(nextUserRatings); + setRatingBanner({ + type: "success", + text: `Thanks for rating ${nickname}!`, + }); + } catch (ratingError) { + console.error("Error saving rating:", ratingError); + setRatingBanner({ + type: "error", + text: ratingError.message || "Couldn't save your rating. Please try again.", + }); + throw ratingError; + } + }; + + const openRatingModal = (printer, rating) => { + setModalState({ open: true, printer, rating }); + setModalMessage(null); + setModalSlackInput(raterSlackId || ""); + }; + + const closeRatingModal = () => { + setModalState({ open: false, printer: null, rating: 0 }); + setModalMessage(null); + setModalSubmitting(false); + }; + + const handleModalSubmit = async () => { + if (!modalState.printer) return; + const trimmedSlack = modalSlackInput.trim(); + if (!trimmedSlack) { + setModalMessage({ type: "error", text: "Please enter your Slack ID." }); + return; + } + + setModalSubmitting(true); + try { + await submitRating( + modalState.printer.slack_id, + modalState.printer.nickname, + modalState.rating, + trimmedSlack + ); + persistRaterSlackId(trimmedSlack); + closeRatingModal(); + } catch (err) { + setModalMessage({ type: "error", text: err.message }); + } finally { + setModalSubmitting(false); + } + }; + if (loading) return (
fetching hack clubbers!!! sit tight
); + if (error) + return ( +
+ {error} +
+ ); if (!printers.length) return
No printers found
; @@ -38,6 +202,7 @@ export default function PrintersPage() { }, {}); return ( + <>
+ {ratingBanner && ( +
+ {ratingBanner.text} +
+ )}

The printers!!

@@ -88,6 +264,14 @@ export default function PrintersPage() { + openRatingModal(selectedPrinter, rating) + } /> ))}

@@ -95,5 +279,57 @@ export default function PrintersPage() { ) )}
+ {modalState.open && ( +
+
+

+ Rate {modalState.printer?.nickname} +

+

+ Slack IDs help us keep ratings fair. You can rate each printer once every 24 hours. +

+ + setModalSlackInput(event.target.value)} + disabled={modalSubmitting} + /> + {modalMessage && ( +
+ {modalMessage.text} +
+ )} +
+ + +
+
+
+ )} + ); } From 0bb2cd937231b721ae13a2a64a773535dfe675f9 Mon Sep 17 00:00:00 2001 From: blaze Date: Sat, 3 Jan 2026 22:07:06 -0500 Subject: [PATCH 2/2] added printing legion message url --- backend/index.js | 18 ++++++++++++- site/src/pages/PrintersPage.jsx | 48 ++++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/backend/index.js b/backend/index.js index 225ebac..b0f630c 100644 --- a/backend/index.js +++ b/backend/index.js @@ -179,7 +179,11 @@ app.get("/api/printers/ratings", async (req, res) => { app.post("/api/printers/:slackId/rate", async (req, res) => { const { slackId } = req.params; - const { rating, rater_slack_id: raterSlackId } = req.body || {}; + const { + rating, + rater_slack_id: raterSlackId, + printing_legion_message_url: messageUrl, + } = req.body || {}; const numericRating = Number(rating); if (!Number.isFinite(numericRating) || numericRating < 1 || numericRating > 5) { @@ -192,6 +196,16 @@ app.post("/api/printers/:slackId/rate", async (req, res) => { return res.status(400).json({ error: "Please include your Slack ID." }); } + if (!messageUrl || typeof messageUrl !== "string") { + return res + .status(400) + .json({ error: "Please include the #printing-legion message link." }); + } + + if (!messageUrl.startsWith("http")) { + return res.status(400).json({ error: "Message link must be a valid URL." }); + } + if (!ratingsTable) { return res.status(500).json({ error: "Ratings table is not configured." }); } @@ -209,10 +223,12 @@ app.post("/api/printers/:slackId/rate", async (req, res) => { slack_id: slackId, rating: numericRating, rater_slack_id: raterSlackId, + printing_legion_message_url: messageUrl, }, }, ]); + const summary = await fetchRatingsSummary(slackId); res.json({ slack_id: slackId, summary: summary[slackId] || { average: 0, count: 0 } }); } catch (error) { diff --git a/site/src/pages/PrintersPage.jsx b/site/src/pages/PrintersPage.jsx index 5b0d12f..36fb874 100644 --- a/site/src/pages/PrintersPage.jsx +++ b/site/src/pages/PrintersPage.jsx @@ -16,6 +16,7 @@ export default function PrintersPage() { const [raterSlackId, setRaterSlackId] = useState(""); const [modalState, setModalState] = useState({ open: false, printer: null, rating: 0 }); const [modalSlackInput, setModalSlackInput] = useState(""); + const [modalMessageUrl, setModalMessageUrl] = useState(""); const [modalMessage, setModalMessage] = useState(null); const [modalSubmitting, setModalSubmitting] = useState(false); @@ -100,14 +101,24 @@ export default function PrintersPage() { } }; - const submitRating = async (slackId, nickname, rating, slackIdentifier) => { + const submitRating = async ( + slackId, + nickname, + rating, + slackIdentifier, + messageUrl + ) => { try { const response = await fetch(`${API_URL}printers/${slackId}/rate`, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ rating, rater_slack_id: slackIdentifier }), + body: JSON.stringify({ + rating, + rater_slack_id: slackIdentifier, + printing_legion_message_url: messageUrl, + }), }); if (!response.ok) { @@ -145,12 +156,14 @@ export default function PrintersPage() { setModalState({ open: true, printer, rating }); setModalMessage(null); setModalSlackInput(raterSlackId || ""); + setModalMessageUrl(""); }; const closeRatingModal = () => { setModalState({ open: false, printer: null, rating: 0 }); setModalMessage(null); setModalSubmitting(false); + setModalMessageUrl(""); }; const handleModalSubmit = async () => { @@ -161,13 +174,30 @@ export default function PrintersPage() { return; } + const trimmedUrl = modalMessageUrl.trim(); + if (!trimmedUrl) { + setModalMessage({ + type: "error", + text: "Drop the #printing-legion message link for this rating.", + }); + return; + } + if (!trimmedUrl.startsWith("http")) { + setModalMessage({ + type: "error", + text: "Message link must start with http or https.", + }); + return; + } + setModalSubmitting(true); try { await submitRating( modalState.printer.slack_id, modalState.printer.nickname, modalState.rating, - trimmedSlack + trimmedSlack, + trimmedUrl ); persistRaterSlackId(trimmedSlack); closeRatingModal(); @@ -300,6 +330,18 @@ export default function PrintersPage() { onChange={(event) => setModalSlackInput(event.target.value)} disabled={modalSubmitting} /> + + setModalMessageUrl(event.target.value)} + disabled={modalSubmitting} + /> {modalMessage && (