Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.env
node_modules/
node_modules/
package-lock.json
Empty file added backend/data/.gitkeep
Empty file.
146 changes: 145 additions & 1 deletion backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,6 +163,80 @@ 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,
printing_legion_message_url: messageUrl,
} = 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 (!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." });
}

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,
printing_legion_message_url: messageUrl,
},
},
]);


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) => {
Expand Down
25 changes: 24 additions & 1 deletion site/src/components/PrinterCard.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
Expand Down Expand Up @@ -43,6 +47,25 @@ export default function PrinterCard({ printer }) {
</a>
)}
</div>
<div className="mt-4">
<div className="flex items-center justify-center gap-2 mb-2">
<span className="text-lg font-semibold">
{count ? average.toFixed(1) : "Unrated"}
</span>
{count > 0 && (
<span className="text-sm text-gray-500">
({count} rating{count > 1 ? "s" : ""})
</span>
)}
</div>
<RatingStars
currentValue={displayValue}
onSelect={(value) => onSelectRating?.(printer, value)}
/>
<p className="text-xs text-gray-500 mt-1">
Tap a star to rate this printer.
</p>
</div>
</div>
</div>
</div>
Expand Down
34 changes: 34 additions & 0 deletions site/src/components/RatingStars.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex gap-1 justify-center">
{Array.from({ length: TOTAL_STARS }).map((_, index) => {
const starValue = index + 1;
const isActive = displayValue >= starValue;
return (
<button
key={starValue}
type="button"
aria-label={`Rate ${starValue} star${starValue > 1 ? "s" : ""}`}
className={`text-2xl transition-colors ${
isActive ? "text-yellow-400" : "text-gray-300"
}`}
onMouseEnter={() => setHoverValue(starValue)}
onMouseLeave={() => setHoverValue(0)}
onFocus={() => setHoverValue(starValue)}
onBlur={() => setHoverValue(0)}
onClick={() => onSelect?.(starValue)}
>
<span>&#9733;</span>
</button>
);
})}
</div>
);
}
4 changes: 2 additions & 2 deletions site/src/pages/Leaderboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
Loading