diff --git a/.gitignore b/.gitignore index 3d70248ba..a1603ed67 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,12 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -package-lock.json \ No newline at end of file +package-lock.json + +# Build output +frontend/dist + +# Presentation files +*.pptx +make_pptx.py +presentation.html \ No newline at end of file diff --git a/Procfile b/Procfile deleted file mode 100644 index dc14c0b63..000000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: npm start --prefix backend \ No newline at end of file diff --git a/README.md b/README.md index 31466b54c..de1e38407 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,163 @@ -# Final Project +# Pejla -Replace this readme with your own information about your project. +A full-stack design feedback platform where users create polls with design alternatives, vote, comment, remix, and collaborate in teams. -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +**Live:** [Frontend](https://pejla.io) | [Backend API](https://pejla-api.onrender.com) -## The problem +## The Problem -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +Designers need quick, structured feedback on their work. Existing tools are either too heavyweight (Figma comments) or too generic (Google Forms). Pejla makes it simple: upload designs, share a link, get votes. + +I built this as my final project for the Technigo Fullstack JavaScript programme. It started with basic poll CRUD and grew into a platform with embeds, media upload, remix trees, teams, and anonymous voting. + +## Features + +- **Create polls** with images, video, audio, or embedded content (Figma, YouTube, CodePen, etc.) +- **Vote** on design alternatives with fullscreen swipe UI +- **Comment** on polls with inline discussion +- **Remix** existing polls to create variations (shown as a tree, not duplicates) +- **Teams & Projects** backend infrastructure (Figma-inspired collaboration) +- **Anonymous voting** — poll creators can allow votes without login +- **Password protection** — restrict access to specific polls +- **Poll lifecycle** — open/close polls, set deadlines, show/hide winner +- **Report system** — flag inappropriate content for admin review +- **Responsive** — works on mobile (320px) to desktop (1600px+) + +## Tech Stack + +### Frontend +- **React 18** with TypeScript +- **React Router 7** — client-side navigation +- **Zustand** — global state management for polls (store) +- **Context API** — authentication state (AuthContext) +- **Tailwind CSS 4** + **shadcn/ui** (Radix UI primitives) +- **Framer Motion** — animations +- **Sonner** — toast notifications +- **Lucide React** — icon library +- **React Dropzone** — drag-and-drop file upload +- **Storybook** — component development and testing + +### Backend +- **Node.js** with **Express** +- **MongoDB** via **Mongoose** +- **bcrypt** — password hashing +- **Cloudinary** + **Multer** — image/video/audio upload +- **RESTful API** with token-based authentication + +## React Hooks Used + +### Standard hooks +- `useState` — local component state +- `useEffect` — side effects (data fetching, event listeners) +- `useContext` — consuming AuthContext + +### React Router hooks +- `useParams` — reading URL parameters (shareId, pollId) +- `useNavigate` — programmatic navigation +- `useLocation` — reading current path (deep link login) +- `useSearchParams` — reading query params (remix flow) + +### Custom hooks (outside curriculum) +- **`useDebounce(value, delay)`** — delays updates until user stops typing. Used for embed URL preview so the iframe doesn't reload on every keystroke. +- **`useMediaQuery(query)`** — tracks CSS media query state. Enables responsive logic in JavaScript (e.g. mobile vs desktop layout decisions). +- **`useAuth()`** — wraps `useContext(AuthContext)` with error boundary. + +### Zustand store +- **`usePollStore()`** — global poll state with `fetchPolls`, `deletePoll`, `reset`. Used in Home and Dashboard to share poll data without prop drilling. + +## External Libraries (beyond React/Express/MongoDB) + +| Library | Purpose | +|---------|---------| +| **zustand** | Global state management (poll store) | +| **framer-motion** | Animations and transitions | +| **sonner** | Toast notifications | +| **lucide-react** | SVG icon components | +| **react-dropzone** | Drag-and-drop file uploads | +| **cloudinary** + **multer-storage-cloudinary** | Cloud media storage | +| **bcrypt** | Secure password hashing | +| **shadcn/ui** (radix-ui, class-variance-authority, tailwind-merge) | Accessible UI components | + +## API Endpoints + +### Auth +| Method | Path | Description | +|--------|------|-------------| +| POST | `/users` | Register | +| POST | `/sessions` | Login | +| GET | `/users/me` | Get profile (auth) | +| PATCH | `/users/me` | Update profile (auth) | +| DELETE | `/users/me` | Delete account (auth) | + +### Polls +| Method | Path | Description | +|--------|------|-------------| +| GET | `/polls` | List public polls | +| GET | `/polls/:shareId` | Get specific poll (password check) | +| POST | `/polls` | Create poll (auth) | +| PATCH | `/polls/:id` | Update poll (auth, owner) | +| DELETE | `/polls/:id` | Delete poll (auth, owner) | +| POST | `/polls/:id/vote` | Vote (auth) | +| POST | `/polls/:id/vote-anonymous` | Anonymous vote | +| POST | `/polls/:id/unvote` | Remove vote (auth) | +| POST | `/polls/:id/remix` | Remix poll (auth) | + +### Comments +| Method | Path | Description | +|--------|------|-------------| +| GET | `/polls/:id/comments` | Get comments | +| POST | `/polls/:id/comments` | Add comment (auth) | +| DELETE | `/comments/:id` | Delete comment (auth, owner) | + +### Teams & Projects +| Method | Path | Description | +|--------|------|-------------| +| POST | `/teams` | Create team (auth) | +| GET | `/teams` | Get my teams (auth) | +| POST | `/teams/join` | Join via invite code (auth) | +| POST | `/teams/:id/invite` | Invite by username (admin) | +| POST | `/teams/:teamId/projects` | Create project (auth) | +| POST | `/projects/:id/polls` | Add poll to project (auth) | + +### Admin +| Method | Path | Description | +|--------|------|-------------| +| GET | `/admin/reports` | View reports (admin) | +| PATCH | `/admin/reports/:id` | Update report status (admin) | + +## Getting Started + +### Backend +```bash +cd backend +npm install +# Create .env with MONGO_URL, CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET +npm run dev +``` + +### Frontend +```bash +cd frontend +npm install +# Create .env with VITE_API_URL=http://localhost:8080 +npm run dev +``` + +### Storybook +```bash +cd frontend +npm run storybook +``` + +## Architecture Decisions + +- **Zustand + Context API**: Zustand handles poll data (global, shared between pages), Context handles auth (needs to wrap the entire app with Provider pattern). +- **Embed URL utility**: Many sites block iframes (X-Frame-Options). The `toEmbedUrl()` utility converts known services to embed format, and shows a "open in new tab" fallback for unsupported sites. +- **Remix tree**: Remixes are hidden from the main feed (`remixedFrom: null` filter) and shown as a tree on the original poll. This prevents feed pollution. +- **Anonymous voting**: Uses a browser fingerprint stored in localStorage for duplicate prevention. +- **Media upload**: Cloudinary with dynamic `resource_type` (image/video/audio). File type detected from mimetype. ## View it live -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file +- **Frontend:** https://pejla.io +- **Backend API:** https://pejla-api.onrender.com diff --git a/backend/README.md b/backend/README.md index d1438c910..6b694cb5b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,8 +1,48 @@ -# Backend part of Final Project +# Pejla Backend -This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with. +Express + MongoDB REST API for the Pejla platform. -## Getting Started +## Setup -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +```bash +npm install +cp .env.example .env # fill in your values +npm run dev +``` + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `MONGO_URL` | MongoDB Atlas connection string | +| `CLOUDINARY_CLOUD_NAME` | Cloudinary cloud name | +| `CLOUDINARY_API_KEY` | Cloudinary API key | +| `CLOUDINARY_API_SECRET` | Cloudinary API secret | + +## Models + +- **User** — username, email, hashed password, role +- **Poll** — title, description, options (image/video/audio/embed), votes, visibility, password, deadline, remix tree +- **Comment** — text, user, poll reference +- **Team** — name, members, invite codes +- **Project** — name, team, polls collection +- **Report** — flagged content for admin review + +## API routes + +| Area | Example endpoints | +|------|-------------------| +| Auth | `POST /users`, `POST /sessions`, `GET /users/me` | +| Polls | `GET /polls`, `POST /polls`, `PATCH /polls/:id`, `DELETE /polls/:id` | +| Voting | `POST /polls/:id/vote`, `POST /polls/:id/vote-anonymous` | +| Comments | `GET /polls/:id/comments`, `POST /polls/:id/comments` | +| Teams | `POST /teams`, `POST /teams/join`, `POST /teams/:id/invite` | +| Upload | `POST /upload` (image, video, audio via Cloudinary) | + +## Tech + +- **Express** — routing and middleware +- **Mongoose** — MongoDB ODM +- **bcrypt** — password hashing +- **Cloudinary + Multer** — media upload and storage +- **Babel** — ES module support diff --git a/backend/middleware/upload.js b/backend/middleware/upload.js new file mode 100644 index 000000000..ab3190c76 --- /dev/null +++ b/backend/middleware/upload.js @@ -0,0 +1,44 @@ +import { v2 as cloudinary } from "cloudinary"; +import { CloudinaryStorage } from "multer-storage-cloudinary"; +import multer from "multer"; + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +const storage = new CloudinaryStorage({ + cloudinary, + params: async (req, file) => { + const isVideo = file.mimetype.startsWith("video/"); + const isAudio = file.mimetype.startsWith("audio/"); + const isImage = file.mimetype.startsWith("image/"); + const isSvg = file.mimetype === "image/svg+xml"; + const isGif = file.mimetype === "image/gif"; + + const resourceType = isVideo || isAudio ? "video" : isImage ? "image" : "raw"; + const transform = isImage && !isSvg && !isGif ? [{ width: 2400, crop: "limit" }] : []; + + return { + folder: "pejla", + resource_type: resourceType, + allowed_formats: [ + "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff", "ico", "heic", "heif", "avif", + "mp4", "mov", "webm", "avi", "mkv", "m4v", + "mp3", "wav", "ogg", "m4a", "flac", "aac", + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", + "md", "txt", "csv", "json", "html", "css", "js", "ts", "jsx", "tsx", + "zip", "sketch", "fig", + ], + transformation: transform, + }; + }, +}); + +const upload = multer({ + storage, + limits: { fileSize: 100 * 1024 * 1024 }, +}); + +export default upload; diff --git a/backend/models/Comment.js b/backend/models/Comment.js new file mode 100644 index 000000000..5ffbeb399 --- /dev/null +++ b/backend/models/Comment.js @@ -0,0 +1,39 @@ +import mongoose from "mongoose"; + +const commentSchema = new mongoose.Schema({ + text: { + type: String, + required: true, + minlength: 1, + maxlength: 500 + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true + }, + username: { + type: String, + required: true + }, + poll: { + type: mongoose.Schema.Types.ObjectId, + ref: "Poll", + required: true + }, + optionIndex: { + type: Number, + default: null + }, + imageUrl: { + type: String, + default: "" + }, + createdAt: { + type: Date, + default: Date.now + } +}); + +const Comment = mongoose.model("Comment", commentSchema); +export default Comment; diff --git a/backend/models/Notification.js b/backend/models/Notification.js new file mode 100644 index 000000000..e878367ab --- /dev/null +++ b/backend/models/Notification.js @@ -0,0 +1,15 @@ +import mongoose from "mongoose"; + +const notificationSchema = new mongoose.Schema({ + user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true }, + type: { type: String, enum: ["vote", "comment", "remix", "mention"], required: true }, + poll: { type: mongoose.Schema.Types.ObjectId, ref: "Poll" }, + fromUser: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, + fromUsername: { type: String, default: "" }, + message: { type: String, required: true }, + read: { type: Boolean, default: false }, + createdAt: { type: Date, default: Date.now } +}); + +const Notification = mongoose.model("Notification", notificationSchema); +export default Notification; diff --git a/backend/models/Poll.js b/backend/models/Poll.js new file mode 100644 index 000000000..775f1216b --- /dev/null +++ b/backend/models/Poll.js @@ -0,0 +1,135 @@ +import mongoose from "mongoose"; +import crypto from "crypto"; + +const optionSchema = new mongoose.Schema({ + label: { + type: String, + required: true, + maxlength: 100 + }, + imageUrl: { + type: String, + default: "" + }, + externalUrl: { + type: String, + default: "" + }, + embedUrl: { + type: String, + default: "" + }, + videoUrl: { + type: String, + default: "" + }, + audioUrl: { + type: String, + default: "" + }, + fileUrl: { + type: String, + default: "" + }, + fileName: { + type: String, + default: "" + }, + textContent: { + type: String, + default: "" + }, + coverUrl: { + type: String, + default: "" + }, + embedType: { + type: String, + enum: ["none", "figma", "lovable", "codepen", "generic"], + default: "none" + }, + votes: [{ type: String }] +}); + +const pollSchema = new mongoose.Schema({ + title: { + type: String, + required: true, + minlength: 3, + maxlength: 100 + }, + description: { + type: String, + maxlength: 500, + default: "" + }, + creator: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true + }, + creatorName: { + type: String, + default: "Anonymous" + }, + options: { + type: [optionSchema], + validate: { + validator: function(arr) { + return arr.length >= 2; + }, + message: "A poll must have at least 2 options" + } + }, + status: { + type: String, + enum: ["draft", "published", "closed"], + default: "published" + }, + showWinner: { + type: Boolean, + default: true + }, + visibility: { + type: String, + enum: ["public", "unlisted", "private"], + default: "public" + }, + shareId: { + type: String, + unique: true, + default: () => crypto.randomUUID().slice(0, 8) + }, + allowRemix: { + type: Boolean, + default: true + }, + allowAnonymousVotes: { + type: Boolean, + default: false + }, + password: { + type: String, + default: "" + }, + remixedFrom: { + type: mongoose.Schema.Types.ObjectId, + ref: "Poll", + default: null + }, + thumbnailUrl: { + type: String, + default: "" + }, + deadline: { + type: Date, + default: null + }, + createdAt: { + type: Date, + default: Date.now + } +}); + +const Poll = mongoose.model("Poll", pollSchema); +export default Poll; diff --git a/backend/models/Project.js b/backend/models/Project.js new file mode 100644 index 000000000..3c9003505 --- /dev/null +++ b/backend/models/Project.js @@ -0,0 +1,35 @@ +import mongoose from "mongoose"; + +const projectSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + maxlength: 50 + }, + description: { + type: String, + default: "", + maxlength: 500 + }, + team: { + type: mongoose.Schema.Types.ObjectId, + ref: "Team", + required: true + }, + creator: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true + }, + polls: [{ + type: mongoose.Schema.Types.ObjectId, + ref: "Poll" + }], + createdAt: { + type: Date, + default: Date.now + } +}); + +const Project = mongoose.model("Project", projectSchema); +export default Project; diff --git a/backend/models/Report.js b/backend/models/Report.js new file mode 100644 index 000000000..5e129b6f7 --- /dev/null +++ b/backend/models/Report.js @@ -0,0 +1,44 @@ +import mongoose from "mongoose"; + +const reportSchema = new mongoose.Schema({ + reason: { + type: String, + required: true, + enum: ["spam", "inappropriate", "copyright", "other"] + }, + message: { + type: String, + maxlength: 500, + default: "" + }, + targetType: { + type: String, + required: true, + enum: ["poll", "comment"] + }, + targetId: { + type: mongoose.Schema.Types.ObjectId, + required: true + }, + reporter: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true + }, + reporterName: { + type: String, + required: true + }, + status: { + type: String, + enum: ["pending", "reviewed", "dismissed"], + default: "pending" + }, + createdAt: { + type: Date, + default: Date.now + } +}); + +const Report = mongoose.model("Report", reportSchema); +export default Report; diff --git a/backend/models/Team.js b/backend/models/Team.js new file mode 100644 index 000000000..3d4969f6f --- /dev/null +++ b/backend/models/Team.js @@ -0,0 +1,41 @@ +import mongoose from "mongoose"; +import crypto from "crypto"; + +const teamSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + maxlength: 50 + }, + description: { + type: String, + default: "", + maxlength: 200 + }, + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true + }, + members: [{ + user: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, + role: { type: String, enum: ["admin", "editor", "viewer"], default: "viewer" }, + joinedAt: { type: Date, default: Date.now } + }], + inviteCode: { + type: String, + unique: true, + default: () => crypto.randomUUID().slice(0, 8) + }, + avatarUrl: { + type: String, + default: "" + }, + createdAt: { + type: Date, + default: Date.now + } +}); + +const Team = mongoose.model("Team", teamSchema); +export default Team; diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 000000000..421560624 --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,50 @@ +import mongoose from "mongoose"; +import bcrypt from "bcrypt"; +import crypto from "crypto"; + +const userSchema = new mongoose.Schema({ + username: { + type: String, + required: true, + unique: true, + minlength: 3, + maxlength: 20 + }, + email: { + type: String, + required: true, + unique: true, + lowercase: true + }, + password: { + type: String, + required: true, + minlength: 8 + }, + role: { + type: String, + enum: ["user", "admin"], + default: "user" + }, + avatarUrl: { + type: String, + default: "" + }, + accessToken: { + type: String, + default: () => crypto.randomUUID() + }, + createdAt: { + type: Date, + default: Date.now + } +}); + +userSchema.pre("save", async function() { + if (this.isModified("password")) { + this.password = await bcrypt.hash(this.password, 10); + } +}); + +const User = mongoose.model("User", userSchema); +export default User; \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f244..dc391c8ed 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,14 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^6.0.0", + "cloudinary": "^1.41.3", "cors": "^2.8.5", + "dotenv": "^17.3.1", "express": "^4.17.3", "mongoose": "^8.4.0", + "multer": "^2.0.2", + "multer-storage-cloudinary": "^4.0.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/server.js b/backend/server.js index 070c87518..2059176c7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,10 +1,17 @@ -import express from "express"; +import "dotenv/config"; import cors from "cors"; +import express from "express"; import mongoose from "mongoose"; - -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +import bcrypt from "bcrypt"; +import crypto from "crypto"; +import User from "./models/User.js"; +import Poll from "./models/Poll.js"; +import Comment from "./models/Comment.js"; +import Report from "./models/Report.js"; +import Notification from "./models/Notification.js"; +import Team from "./models/Team.js"; +import Project from "./models/Project.js"; +import upload from "./middleware/upload.js"; const port = process.env.PORT || 8080; const app = express(); @@ -12,11 +19,994 @@ const app = express(); app.use(cors()); app.use(express.json()); +// Auth middleware +const authenticateUser = async (req, res, next) => { + const token = req.header("Authorization"); + + if (!token) { + return res.status(401).json({ + success: false, + error: "Access denied. No token provided" + }); + } + + try { + const user = await User.findOne({ accessToken: token }); + if (!user) { + return res.status(401).json({ + success: false, + error: "Invalid token" + }); + } + + req.user = user; + next(); + } catch (error) { + res.status(500).json({ + success: false, + error: "Authentication error" + }); + } +}; + app.get("/", (req, res) => { - res.send("Hello Technigo!"); + res.json({ + message: "Welcome to Pejla API", + endpoints: [ + { method: "POST", path: "/users", description: "Register" }, + { method: "POST", path: "/sessions", description: "Login" }, + { method: "GET", path: "/users/me", description: "Get profile (auth)" }, + { method: "GET", path: "/polls", description: "Get all polls" }, + { method: "GET", path: "/polls/:shareId", description: "Get one poll" }, + { method: "POST", path: "/polls", description: "Create poll (auth)" }, + { method: "POST", path: "/polls/:id/vote", description: "Vote (auth)" }, + { method: "PATCH", path: "/polls/:id", description: "Update poll (auth, owner)" }, + { method: "DELETE", path: "/polls/:id", description: "Delete poll (auth, owner)" }, + { method: "POST", path: "/polls/:id/remix", description: "Remix a poll (auth)" }, + { method: "POST", path: "/upload", description: "Upload image (auth)" }, + { method: "GET", path: "/polls/:id/comments", description: "Get comments" }, + { method: "POST", path: "/polls/:id/comments", description: "Add comment (auth)" }, + { method: "DELETE", path: "/comments/:id", description: "Delete comment (auth, owner)" }, + { method: "POST", path: "/reports", description: "Report poll/comment (auth)" }, + { method: "GET", path: "/admin/reports", description: "View reports (admin)" }, + { method: "PATCH", path: "/admin/reports/:id", description: "Update report (admin)" }, + { method: "GET", path: "/notifications", description: "Get notifications (auth)" }, + { method: "GET", path: "/notifications/unread-count", description: "Unread count (auth)" }, + { method: "PATCH", path: "/notifications/:id/read", description: "Mark one read (auth)" }, + { method: "PATCH", path: "/notifications/read-all", description: "Mark all read (auth)" }, + ] + }); +}); + +// Register +app.post("/users", async (req, res) => { + try { + const { username, email, password } = req.body; + + const existingUser = await User.findOne({ email }); + if (existingUser) { + return res.status(400).json({ + success: false, + error: "Email already exists" + }); + } + + const user = new User({ username, email, password }); + const savedUser = await user.save(); + + res.status(201).json({ + success: true, + userId: savedUser._id, + username: savedUser.username, + avatarUrl: savedUser.avatarUrl, + accessToken: savedUser.accessToken + }); + } catch (error) { + res.status(400).json({ + success: false, + error: "Could not create user", + message: error.message + }); + } +}); + +// Login +app.post("/sessions", async (req, res) => { + try { + const { email, password } = req.body; + + const user = await User.findOne({ email }); + if (!user) { + return res.status(401).json({ + success: false, + error: "Invalid email or password" + }); + } + + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res.status(401).json({ + success: false, + error: "Invalid email or password" + }); + } + + user.accessToken = crypto.randomUUID(); + await user.save(); + + res.json({ + success: true, + userId: user._id, + username: user.username, + avatarUrl: user.avatarUrl, + accessToken: user.accessToken + }); + } catch (error) { + res.status(500).json({ + success: false, + error: "Login failed", + message: error.message + }); + } +}); + +// Get profile +app.get("/users/me", authenticateUser, async (req, res) => { + res.json({ + userId: req.user._id, + username: req.user.username, + email: req.user.email, + avatarUrl: req.user.avatarUrl, + createdAt: req.user.createdAt + }); +}); + +// Update avatar +app.patch("/users/me", authenticateUser, async (req, res) => { + try { + const { avatarUrl, username } = req.body; + if (avatarUrl !== undefined) req.user.avatarUrl = avatarUrl; + if (username) req.user.username = username; + await req.user.save(); + res.json({ + success: true, + userId: req.user._id, + username: req.user.username, + avatarUrl: req.user.avatarUrl + }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not update profile", message: error.message }); + } +}); + +// Delete own account +app.delete("/users/me", authenticateUser, async (req, res) => { + try { + await User.findByIdAndDelete(req.user._id); + res.json({ success: true, message: "Account deleted" }); + } catch (error) { + res.status(500).json({ + success: false, + error: "Could not delete account", + message: error.message + }); + } +}); + +// Get all polls +app.get("/polls", async (req, res) => { + try { + const { page = 1, limit = 20, includeRemixes } = req.query; + + const hideRemixes = !includeRemixes; + + // Public feed: only published + public polls + const publicFilter = { status: "published", visibility: { $nin: ["private", "unlisted"] } }; + if (hideRemixes) publicFilter.remixedFrom = null; + + let filter = publicFilter; + + const token = req.header("Authorization"); + if (token) { + const user = await User.findOne({ accessToken: token }); + if (user) { + // Logged-in users also see their own polls (any visibility) + const myFilter = { creator: user._id }; + if (hideRemixes) myFilter.remixedFrom = null; + filter = { + $or: [ + publicFilter, + myFilter + ] + }; + } + } + + const skip = (Number(page) - 1) * Number(limit); + + const polls = await Poll.find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(Number(limit)); + + const total = await Poll.countDocuments(filter); + + const enriched = polls.map((p) => { + const obj = p.toObject(); + obj.totalVotes = obj.options.reduce((sum, opt) => sum + (opt.votes?.length || 0), 0); + return obj; + }); + + res.json({ + total, + page: Number(page), + totalPages: Math.ceil(total / Number(limit)), + results: enriched + }); + } catch (error) { + res.status(500).json({ success: false, error: "Could not fetch polls", message: error.message }); + } +}); + +// Get specific poll +app.get("/polls/:shareId", async (req, res) => { + try { + const poll = await Poll.findOne({ shareId: req.params.shareId }); + + if (!poll) { + return res.status(404).json({ success: false, error: "Poll not found" }); + } + + // Password protection check + if (poll.password) { + const provided = req.header("X-Poll-Password") || req.query.password; + // Owner bypasses password + const token = req.header("Authorization"); + let isOwner = false; + if (token) { + const reqUser = await User.findOne({ accessToken: token }); + if (reqUser && poll.creator.toString() === reqUser._id.toString()) isOwner = true; + } + if (!isOwner && provided !== poll.password) { + return res.status(403).json({ success: false, error: "Password required", requiresPassword: true }); + } + } + + const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes.length, 0); + + const results = poll.options.map((opt) => ({ + label: opt.label, + imageUrl: opt.imageUrl, + videoUrl: opt.videoUrl, + audioUrl: opt.audioUrl, + fileUrl: opt.fileUrl, + fileName: opt.fileName, + externalUrl: opt.externalUrl, + embedUrl: opt.embedUrl, + embedType: opt.embedType, + textContent: opt.textContent, + coverUrl: opt.coverUrl, + voteCount: opt.votes.length, + votes: opt.votes, + percentage: totalVotes > 0 ? Math.round((opt.votes.length / totalVotes) * 100) : 0 + })); + + // Find remixes of this poll + const remixes = await Poll.find({ remixedFrom: poll._id }) + .select("title shareId creatorName createdAt options") + .sort({ createdAt: -1 }); + + const remixData = remixes.map(r => ({ + _id: r._id, + title: r.title, + shareId: r.shareId, + creatorName: r.creatorName, + createdAt: r.createdAt, + thumbnail: r.options[0]?.imageUrl || null, + })); + + // If this is a remix, get the original's shareId + let remixedFromData = null; + if (poll.remixedFrom) { + const original = await Poll.findById(poll.remixedFrom).select("shareId title creatorName"); + if (original) remixedFromData = { shareId: original.shareId, title: original.title, creatorName: original.creatorName }; + } + + res.json({ ...poll.toObject(), totalVotes, results, remixes: remixData, remixedFromData, allowAnonymousVotes: poll.allowAnonymousVotes }); + } catch (error) { + res.status(500).json({ success: false, error: "Could not fetch poll", message: error.message }); + } +}); + +// Beta deadline — creation disabled after this date +const BETA_DEADLINE = new Date("2026-03-31T23:59:59Z"); + +// Beta status +app.get("/beta-status", (req, res) => { + const now = new Date(); + res.json({ + betaOpen: now <= BETA_DEADLINE, + deadline: BETA_DEADLINE.toISOString(), + }); +}); + +// Create poll +app.post("/polls", authenticateUser, async (req, res) => { + if (new Date() > BETA_DEADLINE) { + return res.status(403).json({ + success: false, + error: "Beta has ended. Poll creation is closed. Stay tuned for the next version!" + }); + } + + try { + const { title, description, options, status, allowAnonymousVotes, visibility, password, showWinner, deadline, allowRemix, thumbnailUrl } = req.body; + + const poll = new Poll({ + title, + description, + options, + status: status || "published", + allowAnonymousVotes: allowAnonymousVotes || false, + visibility: visibility || "public", + password: password || "", + showWinner: showWinner !== undefined ? showWinner : true, + deadline: deadline || null, + allowRemix: allowRemix !== undefined ? allowRemix : true, + thumbnailUrl: thumbnailUrl || "", + creator: req.user._id, + creatorName: req.user.username + }); + + const savedPoll = await poll.save(); + + res.status(201).json({ + success: true, + poll: savedPoll, + shareUrl: `/poll/${savedPoll.shareId}` + }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not create poll", message: error.message }); + } +}); + +// Vote poll +app.post("/polls/:id/vote", authenticateUser, async (req, res) => { + try { + const { optionIndex } = req.body; + const poll = await Poll.findById(req.params.id); + + if (!poll) { + return res.status(404).json({ success: false, error: "Poll not found" }); + } + + if (optionIndex < 0 || optionIndex >= poll.options.length) { + return res.status(400).json({ success: false, error: "Invalid option index" }); + } + + // Block votes if poll is closed or deadline passed + if (poll.status === "closed") { + return res.status(400).json({ success: false, error: "This poll is closed" }); + } + if (poll.deadline && new Date() > new Date(poll.deadline)) { + return res.status(400).json({ success: false, error: "Voting deadline has passed" }); + } + + // Remove any existing vote (allows changing vote) + poll.options.forEach(opt => { + const idx = opt.votes.findIndex(v => v.toString() === req.user._id.toString()); + if (idx !== -1) opt.votes.splice(idx, 1); + }); + + poll.options[optionIndex].votes.push(req.user._id); + await poll.save(); + + // Create notification for poll creator + try { + if (poll.creator.toString() !== req.user._id.toString()) { + await new Notification({ + user: poll.creator, + type: "vote", + poll: poll._id, + fromUser: req.user._id, + fromUsername: req.user.username, + message: `${req.user.username} voted on your poll` + }).save(); + } + } catch (notifError) { + console.error("Notification error (vote):", notifError.message); + } + + res.json({ success: true, message: "Vote recorded!" }); + } catch (error) { + res.status(500).json({ success: false, error: "Something went wrong — try again or reach out at hello@pejla.io" }); + } }); -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); +// Anonymous vote (no auth required — only if poll allows it) +app.post("/polls/:id/vote-anonymous", async (req, res) => { + try { + const { optionIndex, fingerprint } = req.body; + const poll = await Poll.findById(req.params.id); + + if (!poll) return res.status(404).json({ success: false, error: "Poll not found" }); + if (!poll.allowAnonymousVotes) return res.status(403).json({ success: false, error: "This poll requires login to vote" }); + if (poll.status === "closed") return res.status(400).json({ success: false, error: "This poll is closed" }); + if (poll.deadline && new Date() > new Date(poll.deadline)) return res.status(400).json({ success: false, error: "Voting deadline has passed" }); + if (optionIndex < 0 || optionIndex >= poll.options.length) return res.status(400).json({ success: false, error: "Invalid option index" }); + + // Use fingerprint string as a pseudo-ID to prevent repeat votes + const anonId = `anon_${fingerprint || req.ip}`; + const alreadyVoted = poll.options.some(opt => opt.votes.some(v => v.toString() === anonId)); + if (alreadyVoted) { + // Remove old vote, add new (change vote) + poll.options.forEach(opt => { + opt.votes = opt.votes.filter(v => v.toString() !== anonId); + }); + } + + poll.options[optionIndex].votes.push(anonId); + await poll.save(); + + // Notify poll creator + try { + await new Notification({ + user: poll.creator, + type: "vote", + poll: poll._id, + fromUsername: "Someone", + message: "Someone voted on your poll" + }).save(); + } catch (notifError) { + console.error("Notification error (anon vote):", notifError.message); + } + + res.json({ success: true, message: "Anonymous vote recorded!" }); + } catch (error) { + res.status(500).json({ success: false, error: "Something went wrong — try again or reach out at hello@pejla.io" }); + } +}); + +// Unvote (remove your vote) +app.post("/polls/:id/unvote", authenticateUser, async (req, res) => { + try { + const poll = await Poll.findById(req.params.id); + + if (!poll) { + return res.status(404).json({ success: false, error: "Poll not found" }); + } + + let removed = false; + poll.options.forEach(opt => { + const idx = opt.votes.findIndex(v => v.toString() === req.user._id.toString()); + if (idx !== -1) { + opt.votes.splice(idx, 1); + removed = true; + } + }); + + if (!removed) { + return res.status(400).json({ success: false, error: "You haven't voted on this poll" }); + } + + await poll.save(); + res.json({ success: true, message: "Vote removed" }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not unvote", message: error.message }); + } }); + +// Update poll (owner of poll) +app.patch("/polls/:id", authenticateUser, async (req, res) => { + try { + const poll = await Poll.findById(req.params.id); + + if (!poll) { + return res.status(404).json({ success: false, error: "Poll not found" }); + } + + if (poll.creator.toString() !== req.user._id.toString()) { + return res.status(403).json({ success: false, error: "Not authorized" }); + } + + const { title, description, status, visibility, options, allowRemix, allowAnonymousVotes, password, showWinner, deadline, thumbnailUrl } = req.body; + if (title) poll.title = title; + if (description !== undefined) poll.description = description; + if (status) poll.status = status; + if (visibility) poll.visibility = visibility; + if (options) poll.options = options; + if (allowRemix !== undefined) poll.allowRemix = allowRemix; + if (allowAnonymousVotes !== undefined) poll.allowAnonymousVotes = allowAnonymousVotes; + if (password !== undefined) poll.password = password; + if (showWinner !== undefined) poll.showWinner = showWinner; + if (deadline !== undefined) poll.deadline = deadline || null; + if (thumbnailUrl !== undefined) poll.thumbnailUrl = thumbnailUrl; + + const updated = await poll.save(); + res.json(updated); + } catch (error) { + res.status(400).json({ success: false, error: "Could not update poll", message: error.message }); + } +}); + +// Remove poll (owner of poll) +app.delete("/polls/:id", authenticateUser, async (req, res) => { + try { + const poll = await Poll.findById(req.params.id); + + if (!poll) { + return res.status(404).json({ success: false, error: "Poll not found" }); + } + + if (poll.creator.toString() !== req.user._id.toString()) { + return res.status(403).json({ success: false, error: "Not authorized" }); + } + + await Comment.deleteMany({ poll: poll._id }); + await Poll.findByIdAndDelete(req.params.id); + + res.json({ success: true, message: "Poll deleted" }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not delete poll", message: error.message }); + } +}); + +// Remix +app.post("/polls/:id/remix", authenticateUser, async (req, res) => { + try { + const original = await Poll.findById(req.params.id); + + if (!original) { + return res.status(404).json({ success: false, error: "Poll not found" }); + } + + if (!original.allowRemix) { + return res.status(403).json({ success: false, error: "This poll does not allow remixes" }); + } + + const { title, description, options } = req.body; + + const remix = new Poll({ + title: title || `Remix: ${original.title}`, + description: description || original.description, + options: options || original.options.map(opt => ({ + label: opt.label, + imageUrl: opt.imageUrl, + externalUrl: opt.externalUrl, + videoUrl: opt.videoUrl, + audioUrl: opt.audioUrl, + fileUrl: opt.fileUrl, + fileName: opt.fileName, + textContent: opt.textContent, + coverUrl: opt.coverUrl, + votes: [] + })), + creator: req.user._id, + creatorName: req.user.username, + remixedFrom: original._id + }); + + const saved = await remix.save(); + + // Create notification for original poll creator + try { + if (original.creator.toString() !== req.user._id.toString()) { + await new Notification({ + user: original.creator, + type: "remix", + poll: original._id, + fromUser: req.user._id, + fromUsername: req.user.username, + message: `${req.user.username} remixed your poll` + }).save(); + } + } catch (notifError) { + console.error("Notification error (remix):", notifError.message); + } + + res.status(201).json({ success: true, poll: saved }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not remix poll", message: error.message }); + } +}); + +// Upload (image, video, audio) +app.post("/upload", authenticateUser, (req, res, next) => { + if (new Date() > BETA_DEADLINE) { + return res.status(403).json({ + success: false, + error: "Beta has ended. Uploads are closed. Stay tuned for the next version!" + }); + } + upload.single("file")(req, res, (err) => { + if (err) { + console.error("Upload error:", err); + return res.status(500).json({ success: false, error: "Upload failed", message: err.message }); + } + next(); + }); +}, (req, res) => { + if (!req.file) { + return res.status(400).json({ success: false, error: "No file provided" }); + } + + const mime = req.file.mimetype || ""; + let fileType = "image"; + if (mime.startsWith("video/")) fileType = "video"; + else if (mime.startsWith("audio/")) fileType = "audio"; + else if (!mime.startsWith("image/")) fileType = "file"; + + res.json({ + success: true, + url: req.file.path, + imageUrl: req.file.path, // backwards compat + fileType, + publicId: req.file.filename + }); +}); + +// Comments +app.get("/polls/:id/comments", async (req, res) => { + try { + const comments = await Comment.find({ poll: req.params.id }).sort({ createdAt: -1 }); + res.json(comments); + } catch (error) { + res.status(500).json({ success: false, error: "Could not fetch comments", message: error.message }); + } +}); + +app.post("/polls/:id/comments", authenticateUser, async (req, res) => { + try { + const { text, optionIndex, imageUrl } = req.body; + + const comment = new Comment({ + text, + user: req.user._id, + username: req.user.username, + poll: req.params.id, + optionIndex: optionIndex ?? null, + imageUrl: imageUrl || "" + }); + + const saved = await comment.save(); + + // Create notification for poll creator + try { + const poll = await Poll.findById(req.params.id); + if (poll && poll.creator.toString() !== req.user._id.toString()) { + await new Notification({ + user: poll.creator, + type: "comment", + poll: poll._id, + fromUser: req.user._id, + fromUsername: req.user.username, + message: `${req.user.username} commented on your poll` + }).save(); + } + } catch (notifError) { + console.error("Notification error (comment):", notifError.message); + } + + res.status(201).json(saved); + } catch (error) { + res.status(400).json({ success: false, error: "Could not create comment", message: error.message }); + } +}); + +app.patch("/comments/:id", authenticateUser, async (req, res) => { + try { + const comment = await Comment.findById(req.params.id); + if (!comment) { + return res.status(404).json({ success: false, error: "Comment not found" }); + } + if (comment.user.toString() !== req.user._id.toString()) { + return res.status(403).json({ success: false, error: "Not authorized" }); + } + const { text, imageUrl } = req.body; + if (text !== undefined) comment.text = text; + if (imageUrl !== undefined) comment.imageUrl = imageUrl; + const saved = await comment.save(); + res.json(saved); + } catch (error) { + res.status(400).json({ success: false, error: "Could not update comment", message: error.message }); + } +}); + +app.delete("/comments/:id", authenticateUser, async (req, res) => { + try { + const comment = await Comment.findById(req.params.id); + + if (!comment) { + return res.status(404).json({ success: false, error: "Comment not found" }); + } + + if (comment.user.toString() !== req.user._id.toString()) { + return res.status(403).json({ success: false, error: "Not authorized" }); + } + + await Comment.findByIdAndDelete(req.params.id); + res.json({ success: true, message: "Comment deleted" }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not delete comment", message: error.message }); + } +}); + +// Report a poll or comment +app.post("/reports", authenticateUser, async (req, res) => { + try { + const { reason, message, targetType, targetId } = req.body; + + const report = new Report({ + reason, + message: message || "", + targetType, + targetId, + reporter: req.user._id, + reporterName: req.user.username + }); + + const saved = await report.save(); + res.status(201).json({ success: true, message: "Report submitted", report: saved }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not submit report", message: error.message }); + } +}); + +// Admin: Get all pending reports +app.get("/admin/reports", authenticateUser, async (req, res) => { + try { + if (req.user.role !== "admin") { + return res.status(403).json({ success: false, error: "Admin access required" }); + } + + const { status = "pending" } = req.query; + const reports = await Report.find({ status }).sort({ createdAt: -1 }); + res.json({ total: reports.length, reports }); + } catch (error) { + res.status(500).json({ success: false, error: "Could not fetch reports", message: error.message }); + } +}); + +// Admin: Update report status +app.patch("/admin/reports/:id", authenticateUser, async (req, res) => { + try { + if (req.user.role !== "admin") { + return res.status(403).json({ success: false, error: "Admin access required" }); + } + + const { status } = req.body; + const report = await Report.findByIdAndUpdate( + req.params.id, + { status }, + { new: true } + ); + + if (!report) { + return res.status(404).json({ success: false, error: "Report not found" }); + } + + res.json({ success: true, report }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not update report", message: error.message }); + } +}); + +// === NOTIFICATIONS === + +// Get current user's notifications +app.get("/notifications", authenticateUser, async (req, res) => { + try { + const notifications = await Notification.find({ user: req.user._id }) + .sort({ createdAt: -1 }) + .limit(50) + .populate("poll", "shareId title"); + res.json({ success: true, notifications }); + } catch (error) { + res.status(500).json({ success: false, error: "Could not fetch notifications", message: error.message }); + } +}); + +// Get unread count +app.get("/notifications/unread-count", authenticateUser, async (req, res) => { + try { + const count = await Notification.countDocuments({ user: req.user._id, read: false }); + res.json({ success: true, count }); + } catch (error) { + res.status(500).json({ success: false, error: "Could not fetch unread count", message: error.message }); + } +}); + +// Mark all as read +app.patch("/notifications/read-all", authenticateUser, async (req, res) => { + try { + await Notification.updateMany({ user: req.user._id, read: false }, { read: true }); + res.json({ success: true, message: "All notifications marked as read" }); + } catch (error) { + res.status(500).json({ success: false, error: "Could not mark notifications as read", message: error.message }); + } +}); + +// Mark one as read +app.patch("/notifications/:id/read", authenticateUser, async (req, res) => { + try { + const notification = await Notification.findById(req.params.id); + if (!notification) { + return res.status(404).json({ success: false, error: "Notification not found" }); + } + if (notification.user.toString() !== req.user._id.toString()) { + return res.status(403).json({ success: false, error: "Not authorized" }); + } + notification.read = true; + await notification.save(); + res.json({ success: true, notification }); + } catch (error) { + res.status(500).json({ success: false, error: "Could not mark notification as read", message: error.message }); + } +}); + +// === TEAMS === + +// Create team +app.post("/teams", authenticateUser, async (req, res) => { + try { + const { name, description } = req.body; + const team = new Team({ + name, + description, + owner: req.user._id, + members: [{ user: req.user._id, role: "admin" }] + }); + const saved = await team.save(); + res.status(201).json({ success: true, team: saved }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not create team", message: error.message }); + } +}); + +// Get my teams +app.get("/teams", authenticateUser, async (req, res) => { + try { + const teams = await Team.find({ "members.user": req.user._id }) + .populate("members.user", "username avatarUrl") + .sort({ createdAt: -1 }); + res.json({ teams }); + } catch (error) { + res.status(500).json({ success: false, error: "Could not fetch teams", message: error.message }); + } +}); + +// Get team by id +app.get("/teams/:id", authenticateUser, async (req, res) => { + try { + const team = await Team.findById(req.params.id) + .populate("members.user", "username avatarUrl email"); + if (!team) return res.status(404).json({ success: false, error: "Team not found" }); + + const isMember = team.members.some(m => m.user._id.toString() === req.user._id.toString()); + if (!isMember) return res.status(403).json({ success: false, error: "Not a member" }); + + const projects = await Project.find({ team: team._id }).populate("polls"); + res.json({ team, projects }); + } catch (error) { + res.status(500).json({ success: false, error: "Could not fetch team", message: error.message }); + } +}); + +// Join team via invite code +app.post("/teams/join", authenticateUser, async (req, res) => { + try { + const { inviteCode } = req.body; + const team = await Team.findOne({ inviteCode }); + if (!team) return res.status(404).json({ success: false, error: "Invalid invite code" }); + + const already = team.members.some(m => m.user.toString() === req.user._id.toString()); + if (already) return res.status(400).json({ success: false, error: "Already a member" }); + + team.members.push({ user: req.user._id, role: "viewer" }); + await team.save(); + res.json({ success: true, team }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not join team", message: error.message }); + } +}); + +// Invite user to team (by username) +app.post("/teams/:id/invite", authenticateUser, async (req, res) => { + try { + const team = await Team.findById(req.params.id); + if (!team) return res.status(404).json({ success: false, error: "Team not found" }); + + const isAdmin = team.members.some( + m => m.user.toString() === req.user._id.toString() && (m.role === "admin" || team.owner.toString() === req.user._id.toString()) + ); + if (!isAdmin) return res.status(403).json({ success: false, error: "Only admins can invite" }); + + const { username, role } = req.body; + const invitedUser = await User.findOne({ username }); + if (!invitedUser) return res.status(404).json({ success: false, error: "User not found" }); + + const already = team.members.some(m => m.user.toString() === invitedUser._id.toString()); + if (already) return res.status(400).json({ success: false, error: "Already a member" }); + + team.members.push({ user: invitedUser._id, role: role || "viewer" }); + await team.save(); + res.json({ success: true, message: `${username} added to team` }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not invite user", message: error.message }); + } +}); + +// Remove member from team +app.delete("/teams/:id/members/:userId", authenticateUser, async (req, res) => { + try { + const team = await Team.findById(req.params.id); + if (!team) return res.status(404).json({ success: false, error: "Team not found" }); + + if (team.owner.toString() !== req.user._id.toString()) { + return res.status(403).json({ success: false, error: "Only owner can remove members" }); + } + + team.members = team.members.filter(m => m.user.toString() !== req.params.userId); + await team.save(); + res.json({ success: true, message: "Member removed" }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not remove member", message: error.message }); + } +}); + +// === PROJECTS === + +// Create project in team +app.post("/teams/:teamId/projects", authenticateUser, async (req, res) => { + try { + const team = await Team.findById(req.params.teamId); + if (!team) return res.status(404).json({ success: false, error: "Team not found" }); + + const member = team.members.find(m => m.user.toString() === req.user._id.toString()); + if (!member || member.role === "viewer") { + return res.status(403).json({ success: false, error: "Not authorized to create projects" }); + } + + const { name, description } = req.body; + const project = new Project({ + name, + description, + team: team._id, + creator: req.user._id + }); + const saved = await project.save(); + res.status(201).json({ success: true, project: saved }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not create project", message: error.message }); + } +}); + +// Add poll to project +app.post("/projects/:id/polls", authenticateUser, async (req, res) => { + try { + const project = await Project.findById(req.params.id); + if (!project) return res.status(404).json({ success: false, error: "Project not found" }); + + const { pollId } = req.body; + if (!project.polls.includes(pollId)) { + project.polls.push(pollId); + await project.save(); + } + res.json({ success: true, project }); + } catch (error) { + res.status(400).json({ success: false, error: "Could not add poll", message: error.message }); + } +}); + +// Connect to MongoDB, then start server +mongoose + .connect(process.env.MONGO_URL) + .then(() => { + console.log("Connected to MongoDB"); + app.listen(port, () => { + console.log(`Server running on http://localhost:${port}`); + }); + }) + .catch((error) => { + console.error("Could not connect to MongoDB:", error.message); + }); diff --git a/demo-content/brief-mobile-checkout.md b/demo-content/brief-mobile-checkout.md new file mode 100644 index 000000000..77d325487 --- /dev/null +++ b/demo-content/brief-mobile-checkout.md @@ -0,0 +1,28 @@ +# Brief: Mobile Checkout Redesign + +## Background +Conversion drops 34% between cart and payment on mobile. Current flow has 4 steps with too many form fields. Competitors (Klarna, Apple Pay) set the bar for one-tap checkout. + +## Goal +Reduce mobile checkout abandonment by 20% within Q2. + +## Target users +- 25-40 year olds shopping on phone during commute/lunch +- Repeat customers who already have saved payment +- First-time buyers who bounce at the sign-up wall + +## Constraints +- Must support Klarna, Swish, Apple Pay, card +- Guest checkout required (no forced account creation) +- Accessibility: WCAG 2.1 AA +- Ship by April 15 + +## What we need to decide +1. Single-page checkout vs. step-by-step? +2. Address autocomplete — Google Places or Loqate? +3. Do we show order summary inline or as a collapsible? + +## Success metrics +- Checkout completion rate: 52% → 65% +- Time to purchase: < 90 seconds on mobile +- Support tickets about checkout: -30% diff --git a/demo-content/option-a-rounded.md b/demo-content/option-a-rounded.md new file mode 100644 index 000000000..bce669d94 --- /dev/null +++ b/demo-content/option-a-rounded.md @@ -0,0 +1,26 @@ +# Button Style: Rounded + +## Design Direction +Soft, approachable, friendly. Think Spotify, Airbnb. + +## Specs +- Border radius: `9999px` (fully rounded) +- Padding: `12px 24px` +- Font: Inter 16px / 600 +- Shadow: `0 1px 3px rgba(0,0,0,0.08)` + +## Colors +| State | Background | Text | +|----------|-----------|---------| +| Default | #6366F1 | #FFFFFF | +| Hover | #4F46E5 | #FFFFFF | +| Disabled | #E5E7EB | #9CA3AF | + +## Pros +- Feels modern and friendly +- Works great on mobile (larger tap targets) +- Strong CTA presence + +## Cons +- Can feel too playful for enterprise +- Harder to align in dense layouts diff --git a/demo-content/option-b-sharp.md b/demo-content/option-b-sharp.md new file mode 100644 index 000000000..38f09d746 --- /dev/null +++ b/demo-content/option-b-sharp.md @@ -0,0 +1,27 @@ +# Button Style: Sharp + +## Design Direction +Clean, professional, editorial. Think Linear, Vercel. + +## Specs +- Border radius: `4px` +- Padding: `10px 20px` +- Font: Inter 14px / 500 +- Shadow: none +- Border: `1px solid rgba(0,0,0,0.1)` + +## Colors +| State | Background | Text | +|----------|-----------|---------| +| Default | #18181B | #FFFFFF | +| Hover | #27272A | #FFFFFF | +| Disabled | #F4F4F5 | #A1A1AA | + +## Pros +- Professional and timeless +- Dense layouts stay clean +- Aligns well with data-heavy UIs + +## Cons +- Can feel cold or corporate +- Smaller tap targets on mobile diff --git a/demo-content/option-c-ghost.md b/demo-content/option-c-ghost.md new file mode 100644 index 000000000..1a89fc460 --- /dev/null +++ b/demo-content/option-c-ghost.md @@ -0,0 +1,29 @@ +# Button Style: Ghost + +## Design Direction +Minimal, content-first, invisible UI. Think Notion, iA Writer. + +## Specs +- Border radius: `6px` +- Padding: `8px 16px` +- Font: Inter 14px / 400 +- Background: transparent +- Border: none +- Underline on hover + +## Colors +| State | Background | Text | +|----------|------------|---------| +| Default | transparent | #18181B | +| Hover | #F4F4F5 | #18181B | +| Disabled | transparent | #D4D4D8 | + +## Pros +- Content stays the hero +- Works great as secondary actions +- Clean, doesn't compete with the page + +## Cons +- Low discoverability (users might miss it) +- Needs strong visual hierarchy elsewhere +- Not great as a primary CTA diff --git a/demo-content/product-plan-design-system.md b/demo-content/product-plan-design-system.md new file mode 100644 index 000000000..9f61d6369 --- /dev/null +++ b/demo-content/product-plan-design-system.md @@ -0,0 +1,44 @@ +# Product Plan: Design System v2 + +## Why now +- 3 squads shipping UI independently — visual inconsistency growing +- Designers spend ~5h/week rebuilding components that already exist +- Brand refresh coming Q3 — need a scalable foundation + +## Vision +One source of truth for UI across web, iOS, and Android. Designers grab from Figma library, devs grab from npm package. Same tokens, same components, same language. + +## What exists today +- Figma library: 42 components, outdated, no documentation +- React component library: 28 components, no tests, no Storybook +- No shared tokens — colors hardcoded everywhere + +## Phase 1: Foundation (4 weeks) +- [ ] Audit existing components across all 3 products +- [ ] Define design tokens: color, spacing, typography, radius, shadows +- [ ] Set up token pipeline: Figma variables → Style Dictionary → CSS/iOS/Android +- [ ] Migrate 10 core components (Button, Input, Card, Modal, Avatar, Badge, Tabs, Toggle, Tooltip, Select) + +## Phase 2: Adoption (4 weeks) +- [ ] Storybook with docs + usage examples +- [ ] npm package with tree-shaking +- [ ] Figma library rebuild with variants + auto-layout +- [ ] Migration guide for each squad +- [ ] Weekly "component clinic" office hours + +## Phase 3: Scale (ongoing) +- [ ] Contribution model — squads can propose new components +- [ ] Visual regression testing (Chromatic) +- [ ] Figma plugin for token inspection +- [ ] Accessibility audit per component + +## Open questions (good for a Pejla poll) +1. Naming convention: `Button` vs `DSButton` vs `@company/button`? +2. Dark mode: ship in v2.0 or v2.1? +3. Icon library: Lucide, Phosphor, or custom? + +## Team +- 1 design systems designer (full-time) +- 1 frontend engineer (full-time) +- 1 PM (20%) +- Squad designers contribute 10% each diff --git a/demo-content/sprint-plan-onboarding.md b/demo-content/sprint-plan-onboarding.md new file mode 100644 index 000000000..011075229 --- /dev/null +++ b/demo-content/sprint-plan-onboarding.md @@ -0,0 +1,40 @@ +# Design Sprint: Onboarding Flow + +## Sprint goal +New users should reach their "aha moment" within 60 seconds of signing up. + +## Day 1 — Map +Current state: 7-step onboarding wizard. 61% of users drop off at step 3 (profile photo upload). Only 23% complete the full flow. + +Key insight from interviews: +> "I just wanted to try the product, not fill out a form about myself" + +## Day 2 — Sketch +Three directions to explore: + +**A) Progressive onboarding** +Skip the wizard entirely. Show empty states with inline prompts. User learns by doing. + +**B) 3-question flow** +Reduce to: name, role, one preference. Everything else is optional and prompted contextually later. + +**C) Template picker** +Skip questions. Let user pick a template/use case. Pre-populate settings based on choice. + +## Day 3 — Decide +Vote on which direction. This is where Pejla comes in — upload mockups of A, B, C and let the team vote. + +## Day 4 — Prototype +Build clickable prototype of winning direction in Figma. + +## Day 5 — Test +5 user tests. Record with Loom. Share clips as Pejla poll: "Which moment confused the user most?" + +## Timeline +| Day | Activity | Output | +|-----|----------|--------| +| Mon | Map current flow | Journey map | +| Tue | Sketch 3 directions | Wireframes | +| Wed | Team vote on Pejla | Winner picked | +| Thu | Figma prototype | Clickable flow | +| Fri | User testing | Insights + clips | diff --git a/docs/AI Integration.md b/docs/AI Integration.md new file mode 100644 index 000000000..57c6b0ee8 --- /dev/null +++ b/docs/AI Integration.md @@ -0,0 +1,190 @@ +# AI Integration + +**Status:** Planned +**Priority:** Medium — differentiator that makes Pejla smarter than a simple poll tool + +## Why + +Design feedback is hard to synthesize. After 30 people vote, reading through comments and making sense of patterns is tedious. AI can turn raw votes + comments into actionable insights in seconds. + +## Integration approach + +All AI features use the **Claude API** (Anthropic). Claude is strong at nuanced analysis, structured reasoning, and creative suggestions — perfect for design feedback. + +### API setup +```javascript +import Anthropic from "@anthropic-ai/sdk"; +const anthropic = new Anthropic({ apiKey: process.env.CLAUDE_API_KEY }); +``` + +--- + +## MVP: AI Vote Summary + +**The simplest, highest-value feature.** After a poll gets enough votes/comments, generate a one-paragraph summary. + +### What it does +Takes all votes, comments, and vote patterns and produces: + +> "Team leans toward Option A (53% of votes). Commenters highlight the clean typography and balanced whitespace. Option B has a vocal minority who prefer its bolder color palette. The main concern with Option A is that the logo mark may not scale well at small sizes. Recommendation: go with Option A but test the logo mark at 16x16." + +### How it works +```javascript +const summary = await anthropic.messages.create({ + model: "claude-sonnet-4-20250514", + max_tokens: 500, + messages: [{ + role: "user", + content: `Summarize this design poll for the creator. + +Poll: "${poll.title}" +Options: ${poll.options.map(o => `${o.label}: ${o.votes} votes`).join(", ")} +Comments: ${comments.map(c => `@${c.author}: "${c.text}"`).join("\n")} + +Write a concise summary (2-3 sentences) of: +1. Which option is winning and why +2. Key themes from comments +3. Any concerns raised +Tone: professional, direct, actionable.` + }] +}); +``` + +### Trigger +- Manual: "Summarize votes" button on poll results page +- Auto: generate when poll closes or hits 10+ votes + +### UI +``` ++------------------------------------------+ +| AI Summary [refresh] | +|------------------------------------------| +| Team leans toward Option A (53%). | +| Commenters highlight the clean | +| typography. Main concern: logo mark | +| may not scale at small sizes. | ++------------------------------------------+ +``` + +### API endpoint +| Method | Path | Description | +|--------|------|-------------| +| POST | `/polls/:id/ai-summary` | Generate/refresh AI summary | + +--- + +## Phase 2: AI-Assisted Poll Creation + +### AI-generated poll suggestions +Upload a design brief or describe a project, and AI suggests poll options: + +**Input:** "We're redesigning our checkout flow. Need feedback on button placement." + +**Output:** +> "Based on your brief, here are 3 poll directions you could create: +> 1. Button position: top vs bottom vs sticky footer +> 2. Button style: primary filled vs outline vs ghost +> 3. CTA copy: 'Buy now' vs 'Add to cart' vs 'Continue'" + +### AI brief generator +Upload a design image and get a structured feedback brief: + +**Input:** Upload screenshot of a landing page + +**Output:** +> "Feedback brief for Landing Page v2: +> - Layout: hero section with left-aligned text, right image +> - Color: blue primary, white background +> - Suggested feedback areas: headline clarity, CTA visibility, image relevance, mobile layout +> - Suggested poll: 'Which headline resonates more?' with 3 variants" + +--- + +## Future Features + +### Smart tagging +Auto-categorize polls based on content analysis: +- UI design +- Branding / logo +- Motion / animation +- Audio / sound design +- Typography +- Color palette +- Layout / spacing +- Illustration +- Icon design + +Tags help with discovery, filtering, and analytics ("What does your team vote on most?"). + +### AI comparison +Side-by-side analysis of design options. AI describes differences between options: + +> "Option A vs Option B: +> - A uses a serif typeface, B uses sans-serif +> - A has more whitespace around the CTA +> - B has higher contrast ratio (better for accessibility) +> - A feels more premium/editorial, B feels more modern/tech" + +### Accessibility checker +AI scans uploaded design images for a11y issues: +- Color contrast ratio estimation +- Text size readability +- Touch target size (mobile) +- Missing alt text suggestions +- Color blindness simulation insights + +Returns a quick score + actionable fixes: + +> "A11y quick check: +> - Contrast: body text on background may not meet WCAG AA (estimated 3.8:1, need 4.5:1) +> - CTA button looks small for mobile (estimate < 44px tap target) +> - Suggestion: increase button padding, darken body text" + +### AI comment insights +Beyond vote summary, analyze comment sentiment and themes: +- Positive/negative/neutral sentiment per option +- Recurring keywords and themes +- Disagreement detection ("Team is split on color choice") +- Suggested follow-up polls based on unresolved debates + +--- + +## Data model + +### AI summary storage +```javascript +const aiSummarySchema = new mongoose.Schema({ + poll: { type: ObjectId, ref: "Poll", required: true, index: true }, + summary: { type: String, required: true }, + model: { type: String, default: "claude-sonnet-4-20250514" }, + votesAtGeneration: Number, // snapshot so we know when to refresh + commentsAtGeneration: Number, + tokens: Number, // track usage + createdAt: { type: Date, default: Date.now } +}); +``` + +## Cost management +- Cache summaries — only regenerate when votes/comments change significantly +- Use `claude-sonnet-4-20250514` (fast + cheap) for summaries, `claude-opus-4-20250514` for deeper analysis +- Rate limit: max 1 AI call per poll per 5 minutes +- Free tier: 3 AI summaries/month. Pro: unlimited. Team: unlimited + AI comparison +- Track token usage per user for billing + +## API endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/polls/:id/ai-summary` | Generate vote summary | +| POST | `/polls/:id/ai-compare` | Compare options (Phase 2) | +| POST | `/ai/brief` | Generate feedback brief from image | +| POST | `/ai/suggest-poll` | Suggest poll from text prompt | +| POST | `/ai/a11y-check` | Accessibility check on image | + +## Ties into + +- [[Slack Integration]] — AI summaries can be auto-posted to Slack when a poll closes +- [[Figma Plugin]] — "AI Brief" button in plugin to generate feedback questions +- [[Browser Extension]] — Show AI summary in extension popup for quick review +- [[Notifications]] — Notify poll creator when AI summary is ready +- [[Beta Launch Plan]] — AI features in roadmap as future differentiator diff --git a/docs/API Reference.md b/docs/API Reference.md new file mode 100644 index 000000000..d772f35c1 --- /dev/null +++ b/docs/API Reference.md @@ -0,0 +1,64 @@ +# API Reference + +Base URL: `https://api.pejla.io` (production) / `http://localhost:8080` (dev) + +Auth: Send `Authorization: ` header for authenticated endpoints. + +## Auth + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/users` | No | Register (username, email, password) | +| POST | `/sessions` | No | Login (email, password) -> returns token | +| GET | `/users/me` | Yes | Get current user profile | + +## Polls + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/polls` | No | List all polls (returns public + unlisted) | +| GET | `/polls/:shareId` | No* | Get single poll by shareId (*password required if set) | +| POST | `/polls` | Yes | Create poll | +| PATCH | `/polls/:id` | Yes | Update poll (owner only) | +| DELETE | `/polls/:id` | Yes | Delete poll (owner only) | +| POST | `/polls/:id/vote` | Yes | Vote on an option | +| POST | `/polls/:id/vote-anonymous` | No | Anonymous vote (fingerprint in body) | +| POST | `/polls/:id/unvote` | Yes | Remove vote | +| POST | `/polls/:id/remix` | Yes | Fork a poll | + +## Files + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/upload` | Yes | Upload file to Cloudinary | + +## Comments + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/polls/:id/comments` | No | List comments for a poll | +| POST | `/polls/:id/comments` | Yes | Add comment | +| DELETE | `/comments/:id` | Yes | Delete comment (owner only) | + +## Reports (admin) + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/reports` | Yes | Report a poll or comment | +| GET | `/admin/reports` | Admin | View all reports | +| PATCH | `/admin/reports/:id` | Admin | Update report status | + +## Poll create body example +```json +{ + "title": "Which logo variant?", + "description": "Choosing between 3 directions", + "options": [ + { "label": "Option A", "imageUrl": "https://res.cloudinary.com/..." }, + { "label": "Option B", "imageUrl": "https://res.cloudinary.com/..." } + ], + "visibility": "public", + "allowAnonymousVotes": true, + "allowRemix": true +} +``` diff --git a/docs/Architecture.md b/docs/Architecture.md new file mode 100644 index 000000000..55af7ba95 --- /dev/null +++ b/docs/Architecture.md @@ -0,0 +1,89 @@ +# Architecture + +## Stack + +| Layer | Tech | +|-------|------| +| Frontend | Vite + React 18 + TypeScript | +| Styling | Tailwind v4 + shadcn/ui | +| State | Zustand | +| Backend | Express.js + Node | +| Database | MongoDB Atlas + Mongoose | +| Auth | bcrypt + access token (UUID) | +| File storage | Cloudinary (image/video/audio/raw) | +| Hosting | Render | +| Domain | pejla.io | + +## Monorepo structure + +``` +project-final/ + frontend/ # Vite React app + src/ + api/ # polls.ts, auth.ts (API client + XHR upload) + components/ # ui/ (shadcn), AuthModal, TextFilePreview + pages/ # Home, CreatePoll, EditPoll, VotePoll, Profile + stores/ # pollStore.ts (Zustand) + utils/ # embedUrl.ts (URL-to-embed converter) + backend/ + server.js # All endpoints, auth middleware + middleware/ # upload.js (Cloudinary) + models/ # User, Poll, Comment, Team, Project, Report + docs/ # This Obsidian vault +``` + +## Data models + +### User +- `username` (unique, 3-20 chars) +- `email` (unique, lowercase) +- `password` (bcrypt hashed) +- `role` (user | admin) +- `avatarUrl` +- `accessToken` (UUID, generated on create) + +### Poll +- `title` (3-100 chars) +- `description` (max 500) +- `creator` (ref User) +- `creatorName` +- `options[]` — each option has: + - `label`, `imageUrl`, `externalUrl`, `embedUrl` + - `videoUrl`, `audioUrl`, `fileUrl`, `fileName` + - `embedType` (none | figma | lovable | codepen | generic) + - `votes[]` (ref User) +- `status` (draft | published | closed) +- `visibility` (public | unlisted | private) +- `shareId` (8-char UUID slug) +- `password` (optional, plain text gate) +- `allowAnonymousVotes`, `allowRemix`, `showWinner` +- `deadline`, `remixedFrom` + +### Team +- `name`, `description`, `owner` (ref User) +- `members[]` — each: `user`, `role` (admin | editor | viewer), `joinedAt` +- `inviteCode` (8-char UUID) + +### Project +- `name`, `description` +- `team` (ref Team), `creator` (ref User) +- `polls[]` (ref Poll) + +### Comment +- Linked to poll + option, supports threaded replies + +### Report +- Content moderation for polls/comments + +## API endpoints + +See [[API Reference]] + +## Key patterns + +- **XHR for uploads** — `fetch()` doesn't support upload progress events. We use XHR with `onprogress` for the progress bar + cancel button. +- **Anonymous fingerprint** — `Math.random().toString(36) + Date.now().toString(36)` stored in localStorage. Sent as `fingerprint` on anonymous vote. +- **Owner bypass** — Poll owner skips password gate by checking `req.user._id === poll.creator`. +- **Unlisted filtering** — Server returns all polls. Client filters: `polls.filter(p => !p.visibility || p.visibility === "public")`. +- **Embed fallback** — Non-embeddable URLs show a card with Google favicon API + "Open in new tab" link. +- **Cloudinary routing** — Middleware auto-detects mimetype and routes to image/video/raw. SVG + GIF skip resize transforms. diff --git a/docs/Beta Launch Plan.md b/docs/Beta Launch Plan.md new file mode 100644 index 000000000..f449f9ee4 --- /dev/null +++ b/docs/Beta Launch Plan.md @@ -0,0 +1,177 @@ +# Beta Launch Plan + +## Overview + +Launch in phases. Don't go Product Hunt on day 1. Build confidence, get real feedback, fix the rough edges, then go public. + +## Pre-launch checklist + +### Clean up for production +- [ ] Remove Technigo bootcamp references (footer, README, any school-specific copy) +- [ ] Remove test/dummy polls from database +- [ ] Audit all copy — no "lorem ipsum", no placeholder text +- [ ] Custom 404 page +- [ ] Error boundary with friendly message +- [ ] Loading states everywhere (skeleton screens) +- [ ] Mobile responsive audit (every page) +- [ ] Lighthouse audit: aim for 90+ on all metrics +- [ ] Meta tags (title, description, OG image for social sharing) +- [ ] Favicon + apple-touch-icon (already done via PWA) +- [ ] HTTPS everywhere +- [ ] Rate limiting on API (prevent abuse) +- [ ] Input sanitization audit (XSS, injection) +- [ ] Environment variables: no secrets in code + +### Demo content +- [ ] Create 5-8 high-quality example polls that showcase the platform: + - Logo comparison (3 variants) + - UI component vote (buttons, cards) + - Color palette decision + - Landing page A/B test + - Mobile vs desktop layout + - Icon style vote (outline vs filled) +- [ ] Use real design assets (your own work or with permission) +- [ ] Show variety: images, embeds (Figma site), video +- [ ] Have friends vote on them so they show real vote counts + +### Analytics +- [ ] Set up Plausible or PostHog (privacy-friendly, no cookie banner needed) +- [ ] Track key events: + - Page views (landing, create, vote, results) + - Poll created + - Vote cast + - Share link copied + - Sign up + - Login + - Upgrade click (for later) +- [ ] Set up conversion funnel: Visit -> Sign up -> Create poll -> Get votes +- [ ] Error tracking: Sentry (free tier) + +### Legal +- [ ] Privacy policy page (GDPR compliant — you're in Sweden) +- [ ] Terms of service +- [ ] Cookie policy (if using analytics cookies) +- [ ] Data deletion capability (GDPR right to be forgotten) + +--- + +## Phase 1: Friends & Family (Week 1-2) + +**Goal:** Find obvious bugs, validate core flow works + +- Share with 10-15 people you trust (classmates, designer friends, Qasa friend) +- Ask them to: create a poll, vote on yours, share with 1 person +- Collect feedback via a Pejla poll (meta!) +- Fix critical bugs + +**Success metric:** 10 polls created, 50 votes cast, no critical bugs + +## Phase 2: Design Community Beta (Week 3-4) + +**Goal:** Test with real designers, get organic feedback + +### Distribution channels +- Post in design Slack/Discord communities +- Share on LinkedIn (your network + design groups) +- Tweet/X with demo GIF showing the flow +- Post on Designers Sverige / Swedish design communities +- Ask Qasa friend to try it with their team (real internal use case!) + +### Messaging +> "I built a tool to fix broken design feedback. Instead of 'I like the first one' in Slack — share a link, vote, decide. Free, no sign-up needed to vote. Would love your feedback." + +- Include a link to a real poll they can vote on (experience it before signing up) +- Show, don't tell — GIF/video of the 30-second flow + +**Success metric:** 50 users, 30 polls, NPS > 7 + +## Phase 3: Product Hunt Launch (Week 5-6) + +### Product Hunt prep +- [ ] Create maker profile on Product Hunt +- [ ] Get 5+ hunter friends to upvote early +- [ ] Prepare launch assets: + - [ ] Tagline: "Design feedback, without the noise" + - [ ] Description (250 chars): "Share design options, collect votes, decide in minutes. Supports Figma, images, video. Free forever." + - [ ] Gallery images (5-6): + 1. Hero shot (landing page) + 2. Create poll flow + 3. Vote experience (mobile) + 4. Results view + 5. Figma embed in action + 6. Before/after: Slack thread vs Pejla poll + - [ ] Demo video (60-90 seconds): problem -> solution -> CTA + - [ ] First comment (maker comment explaining why you built it) +- [ ] Schedule launch for Tuesday/Wednesday (best days) +- [ ] Launch at 00:01 PST (Product Hunt resets at midnight PST) +- [ ] Be available all day to respond to comments + +### Product Hunt description template +``` +Problem: Design feedback is broken. "I prefer the first one" is not actionable. Slack threads spiral. Meetings waste time. + +Pejla fixes this: +1. Share — upload designs, paste Figma/YouTube links +2. Vote — one tap, no sign-up needed +3. Decide — see results instantly + +Built by a designer who was tired of "can you just make it pop more?" + +Free forever. Pro + Team plans coming soon. +``` + +**Success metric:** Top 10 of the day, 200+ upvotes, 100 sign-ups + +## Phase 4: Growth (Week 7+) + +### Before sharing with design teams / clients +- [ ] Stripe integration live (so people can actually upgrade) +- [ ] Notifications working (users come back) +- [ ] Team features in UI (not just models) +- [ ] Figma plugin MVP (huge differentiator) +- [ ] [[Slack Integration]] MVP — incoming webhook to post results to team channels (launch differentiator for design teams already living in Slack) +- [ ] Custom domain + professional email (hello@pejla.io) +- [ ] Support channel (email or Discord) + +### LinkedIn strategy +- Write 3-4 posts about the journey: + 1. "I built a tool to fix design feedback" (launch post) + 2. "What I learned building Pejla" (builder story) + 3. "How [Qasa/company] uses Pejla for design decisions" (case study) + 4. "Pejla now has a Figma plugin" (feature launch) +- Tag relevant people, use #design #productdesign #ux hashtags +- Post poll results as social proof ("142 designers voted, Option B won") + +### Client outreach +- Identify 5-10 agencies/teams who might use it +- Offer free Pro for 3 months in exchange for feedback +- Build case studies from their usage + +--- + +## Timeline summary + +| Week | Phase | Focus | +|------|-------|-------| +| 1-2 | Friends & Family | Bug fixes, core flow | +| 3-4 | Design Community | Real users, messaging | +| 5-6 | Product Hunt | Public launch | +| 7-8 | Stripe + Notifications | Monetization | +| 9-10 | Figma Plugin MVP | Distribution | +| 11-12 | [[Slack Integration]] MVP | Team adoption | +| 13-14 | LinkedIn + Outreach | Growth | +| 15+ | [[AI Integration]] (vote summaries, smart tagging) | Differentiation | + +## Key metrics to track + +| Metric | Target (3 months) | +|--------|-------------------| +| Registered users | 500 | +| Polls created | 200 | +| Votes cast | 2,000 | +| DAU | 50 | +| Pro conversions | 10 | +| Team conversions | 3 | +| Figma plugin installs | 100 | +| Slack workspaces connected | 20 | +| AI summaries generated | 50 | diff --git a/docs/Browser Extension.md b/docs/Browser Extension.md new file mode 100644 index 000000000..fdf857dbb --- /dev/null +++ b/docs/Browser Extension.md @@ -0,0 +1,132 @@ +# Browser Extension + +**Status:** Planned +**Priority:** Medium — complements the Figma plugin + +## Why + +Not everything lives in Figma. Designers review: +- Deploy previews (Vercel, Netlify) +- Competitor websites +- Dribbble/Behance shots +- Prototypes in browser +- Storybook instances + +The extension lets you capture any URL or screenshot and send it to Pejla with one click. + +## MVP scope + +### Core flow +1. Visit any webpage +2. Click Pejla extension icon +3. Choose: "Capture screenshot" or "Share URL" +4. Add to new poll or existing poll +5. Get shareable link + +### Extension popup UI +``` ++----------------------------------+ +| Pejla | +|----------------------------------| +| Current page: | +| [screenshot preview] | +| dribbble.com/shots/12345 | +| | +| [Screenshot] [URL only] | +| | +| Add to: | +| ( ) New poll | +| ( ) Existing: [dropdown] | +| | +| Label: [auto from page title] | +| | +| [ Send to Pejla ] | +| | +| --- | +| Recent polls: | +| - Logo v3 (4 votes) | +| - Header variants (12 votes) | ++----------------------------------+ +``` + +### Features (MVP) +- Capture visible tab as screenshot (Chrome `captureVisibleTab` API) +- Send current URL as embeddable option (if supported) or fallback card +- Auto-label from page `` +- Create new poll or add to existing +- Copy share link +- Badge icon showing unread notification count (ties into [[Notifications]]) + +### Features (v2) +- Area selection (crop before sending) +- Full-page screenshot +- Right-click context menu: "Send image to Pejla" +- "Compare this page" — capture same URL at different times +- Annotate screenshot before sending +- Send poll results to a connected Slack channel via [[Slack Integration]] +- [[AI Integration]] summary — show AI-generated vote summary in the extension popup for quick review + +## Technical approach + +### Manifest V3 (Chrome) +```json +{ + "manifest_version": 3, + "name": "Pejla", + "version": "1.0.0", + "description": "Share designs, collect votes", + "permissions": ["activeTab", "storage"], + "action": { + "default_popup": "popup.html", + "default_icon": "icon-48.png" + }, + "background": { + "service_worker": "background.js" + }, + "icons": { + "16": "icon-16.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} +``` + +### Screenshot capture +```typescript +// In popup or background script +chrome.tabs.captureVisibleTab(null, { format: "png", quality: 90 }, (dataUrl) => { + // dataUrl is base64 PNG — upload to Pejla API +}); +``` + +### Auth +- Store access token in `chrome.storage.local` +- Login flow: open pejla.io/login in new tab, redirect back with token +- Or: inline login form in popup + +### File structure +``` +browser-extension/ + manifest.json + popup.html / popup.tsx # Main UI + background.js # Service worker + content.js # (v2) for area selection overlay + icons/ +``` + +### Cross-browser +- Start with Chrome (largest market share among designers) +- Firefox: minimal changes (manifest v2 still supported) +- Safari: later (requires Xcode wrapper) + +## New API endpoints needed + +Same as [[Figma Plugin]]: +- `POST /polls/:id/options` — add options to existing poll +- `POST /upload/base64` — upload from base64 data URL +- `GET /users/me/polls` — list user's polls + +## Distribution +- Chrome Web Store (free to publish, $5 one-time fee) +- Cross-promote from Figma plugin + pejla.io +- "Capture anything" messaging diff --git a/docs/Features.md b/docs/Features.md new file mode 100644 index 000000000..48b73ad8f --- /dev/null +++ b/docs/Features.md @@ -0,0 +1,81 @@ +# Features + +## Core (shipped) + +### Poll creation +- 3-step flow: Options -> Question -> Publish +- Upload images, video, audio, or any file via Cloudinary +- Paste URLs (Figma, YouTube, Vimeo, Loom, CodePen, Spotify, etc.) +- Paste-anywhere with `findTarget()` — pastes into selected option, empty slot, or creates new card +- Auto-generated title from first option label +- Min 2 options required +- "Write text" button — create inline markdown content without file upload +- Cover/thumbnail upload for audio, video, text, and PDF options + +### Media support +- **Images** — PNG, JPG, WebP, SVG, GIF (Cloudinary CDN) +- **Video** — MP4, WebM (Cloudinary video player) +- **Audio** — MP3, WAV (native audio element) +- **Embeds** — YouTube, Vimeo, Loom, CodePen, CodeSandbox, Spotify, Figma Sites +- **Text files** — .md, .txt, .csv rendered inline with react-markdown +- **Inline text** — write markdown directly in `textContent` field (no upload) +- **External URLs** — Non-embeddable links show fallback card with favicon +- **Deploy previews** — Vercel, Netlify, GitHub Pages, Render, Railway, etc. embedded as iframe + +### Voting +- One-tap voting (authenticated) +- Anonymous voting via localStorage fingerprint (toggle per poll) +- Change vote (unvote + revote) +- Deep-link to specific option via `/poll/:shareId/option/N` +- Media auto-stops on slide change (audio/video pause) + +### Privacy & access +- **Public** — visible in feed + searchable +- **Unlisted** — accessible via link, hidden from public feed +- **Private** — password-protected (owner bypasses password) + +### Results +- Live vote counts + percentages +- Winner highlight (toggleable) +- Comments on polls (with optional image) +- Remix — fork any poll to create variations (all formats supported) + +### Notifications +- In-app notification bell with unread count badge +- Auto-created on vote, comment, and remix +- Polls every 30s for new notifications +- Mark all as read / individual read +- Links directly to the poll + +### Landing page +- Logo intro animation (plays once per session) +- Sequential word animation: "Design feedback" -> "Client reviews" -> "Project briefs" -> etc. +- Floating Figma-style cursors (Mia/Alex/Sam) with path animation + click ripple +- Hero poll preview card (scroll parallax fade-out) +- Focus carousel for recent polls with card stack effect +- Quote carousel with designer pain points +- "How it works" 3-step section with animated illustrations +- Copy: "Share designs, videos, briefs, or tracks — collect votes and see which idea wins." + +### UI/UX polish +- Custom Figma-style cursor (light/dark mode, matches hero cursors) +- Apple-style subtle scrollbar (thin, rounded, works on macOS + Windows) +- Paper-style markdown rendering (white card, shadow, prose typography) +- See-through edit panel (narrow, transparent backdrop) +- Smart thumbnails on Dashboard/Home (text preview, audio icon, cover images) +- Upload progress with cancel for both files and covers +- PWA ready (manifest, icons) +- Storybook component library +- Share button copies poll URL with current option +- Reduced motion support + +## Planned + +- [[Stripe Integration]] — paid tiers +- [[Figma Plugin]] — export frames directly to Pejla +- [[Browser Extension]] — capture current page/URL to Pejla +- [[Slack Integration]] — post results to team channels +- [[AI Integration]] — vote summaries, smart tagging, sentiment +- Teams & Projects — already have models, need UI +- Real-time notifications (WebSocket/SSE) +- Analytics dashboard (PostHog/Plausible) diff --git a/docs/Figma Plugin.md b/docs/Figma Plugin.md new file mode 100644 index 000000000..08fcae299 --- /dev/null +++ b/docs/Figma Plugin.md @@ -0,0 +1,128 @@ +# Figma Plugin + +**Status:** Planned +**Priority:** High — this is the #1 distribution channel + +## Why this is key + +Designers live in Figma. If Pejla is one click away from their canvas, adoption becomes frictionless. No copy-paste, no export-upload-share dance. Select frames -> create poll -> share link. Done. + +## MVP scope + +### Core flow +1. Select 2+ frames/components in Figma +2. Click "Send to Pejla" in plugin panel +3. Plugin exports each frame as PNG (or uses Figma's `exportAsync`) +4. Uploads to Pejla API (via Cloudinary) +5. Creates a new poll (or adds to existing poll) +6. Returns shareable link: `pejla.io/poll/abc123` + +### Plugin UI (minimal) +``` ++----------------------------------+ +| Pejla [?] | +|----------------------------------| +| Selected: 3 frames | +| | +| [thumbnails of selected frames] | +| | +| Title: [auto from page name] | +| [ ] Anonymous voting | +| [ ] Add to existing poll [v] | +| | +| [ Create poll & share ] | +| | +| --- or --- | +| | +| Your polls: | +| - Logo v3 (4 votes) [>] | +| - Header variants (12 votes)[>] | +|----------------------------------| +| Comments on "Logo v3": | +| @mia: "Top left feels heavy" | +| @alex: "Love the contrast" | ++----------------------------------+ +``` + +### Features (MVP) +- Export selected frames as PNG (72dpi for speed, 2x for quality toggle) +- Auto-title from Figma page/frame name +- Create new poll or add options to existing poll +- Copy share link to clipboard +- View your polls list + vote counts +- View comments on each poll +- Deep-link back to Figma frame from poll option (store Figma node URL) + +### Features (v2) +- Live preview — changes in Figma auto-update poll options +- Export as SVG or PDF +- Team workspace — see team polls +- Figma comments <-> Pejla comments sync +- Notification badge in plugin when new votes arrive +- "Compare versions" — select same frame from different pages/branches +- Vote counts visible inside Figma — see how each frame/option is performing without leaving the canvas +- [[Slack Integration]] — show which Slack channel a poll was posted to, and surface Slack channel notifications directly in the plugin panel +- "AI Brief" button — generate a structured feedback brief from selected frames via [[AI Integration]] + +## Technical approach + +### Figma Plugin API +```typescript +// Export selected nodes as PNG +const selection = figma.currentPage.selection; +for (const node of selection) { + const bytes = await node.exportAsync({ + format: "PNG", + constraint: { type: "SCALE", value: 2 } + }); + // Send bytes to Pejla API +} +``` + +### Auth +- OAuth or access token stored in `figma.clientStorage` +- Login once, stay logged in +- `figma.clientStorage.setAsync("pejla-token", token)` + +### API calls from plugin +- Figma plugins can make network requests via `figma.ui.postMessage` + iframe UI +- Plugin UI (iframe) calls Pejla API directly +- Upload flow: export PNG bytes -> base64 -> POST to `/upload` -> get URL -> create poll + +### File structure +``` +figma-plugin/ + manifest.json # Figma plugin manifest + code.ts # Plugin sandbox (Figma API access) + ui.html # Plugin UI (iframe, can use React) + ui.tsx # React UI for the panel +``` + +### Figma manifest.json +```json +{ + "name": "Pejla", + "id": "pejla-figma-plugin", + "api": "1.0.0", + "main": "code.js", + "ui": "ui.html", + "editorType": ["figma"], + "networkAccess": { + "allowedDomains": ["https://api.pejla.io", "https://res.cloudinary.com"] + } +} +``` + +## New API endpoints needed + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/polls/:id/options` | Add options to existing poll | +| POST | `/upload/base64` | Upload from base64 (plugin can't do multipart easily) | +| GET | `/users/me/polls` | List current user's polls (for "add to existing") | + +## Distribution +- Publish to Figma Community (free) +- "Made by a designer, for designers" angle +- Launch on Twitter/X + Figma community +- Show in Pejla landing page: "Works with Figma" diff --git a/docs/Notifications.md b/docs/Notifications.md new file mode 100644 index 000000000..cc4f9773e --- /dev/null +++ b/docs/Notifications.md @@ -0,0 +1,130 @@ +# Notifications + +**Status:** Planned +**Priority:** High — drives engagement + return visits + +## Why + +Users create a poll and leave. Without notifications, they have to manually check back. A notification bell keeps them engaged and drives return visits. + +## What triggers a notification + +| Event | Who gets notified | Message | +|-------|-------------------|---------| +| New vote | Poll creator | "Someone voted on 'Logo v3'" | +| Vote milestone | Poll creator | "'Logo v3' reached 10 votes" | +| New comment | Poll creator | "@mia commented on 'Logo v3'" | +| Comment reply | Comment author | "@alex replied to your comment" | +| Poll deadline approaching | Poll creator | "'Logo v3' closes in 1 hour" | +| Poll closed / deadline hit | Poll creator + voters | "'Logo v3' is closed — see results" | +| Poll remixed | Original creator | "@sam remixed 'Logo v3'" | +| Team invite | Invited user | "You were invited to team 'Design'" | + +## MVP scope + +### In-app notification bell +- Bell icon in header navbar (next to user avatar) +- Red badge with unread count +- Dropdown panel showing recent notifications +- Click notification -> navigate to relevant poll +- "Mark all as read" button +- Persist read/unread state in database + +### UI +``` + [bell icon with red dot "3"] + | + v + +----------------------------------+ + | Notifications [clear] | + |----------------------------------| + | * @mia voted on "Logo v3" | + | 2 minutes ago | + |----------------------------------| + | * "Header variants" hit 10 | + | votes! — 1 hour ago | + |----------------------------------| + | @alex commented: "Love the | + | contrast on option B" | + | 3 hours ago | + |----------------------------------| + | [View all notifications] | + +----------------------------------+ +``` + +## Data model + +### Notification schema +```javascript +const notificationSchema = new mongoose.Schema({ + recipient: { type: ObjectId, ref: "User", required: true, index: true }, + type: { + type: String, + enum: ["vote", "vote_milestone", "comment", "comment_reply", + "deadline", "poll_closed", "remix", "team_invite"], + required: true + }, + poll: { type: ObjectId, ref: "Poll" }, + comment: { type: ObjectId, ref: "Comment" }, + actor: { type: ObjectId, ref: "User" }, // who triggered it + actorName: String, // denormalized for speed + message: String, // pre-rendered text + read: { type: Boolean, default: false, index: true }, + createdAt: { type: Date, default: Date.now, index: true } +}); +``` + +## API endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/notifications` | List notifications (auth, paginated) | +| GET | `/notifications/unread-count` | Get unread count (for badge) | +| PATCH | `/notifications/:id/read` | Mark one as read | +| PATCH | `/notifications/read-all` | Mark all as read | + +## Implementation plan + +### Phase 1: In-app (MVP) +1. Create Notification model +2. Add notification creation logic to vote/comment/remix endpoints +3. Add GET/PATCH notification endpoints +4. Build NotificationBell component in navbar +5. Poll for unread count every 30s (or on focus) + +### Phase 2: Real-time +- WebSocket (Socket.io) for instant notifications +- Push notification count to bell without polling + +### Phase 3: External (post-MVP) +- Email digest (daily/weekly summary) +- Browser push notifications (Web Push API) +- [[Slack Integration]] as a full notification channel: + - Incoming webhook: post vote/comment/close events to a Slack channel + - Per-team and per-user settings for which events trigger Slack messages + - Interactive Slack messages: vote directly from the notification + - AI vote summaries auto-posted to Slack when a poll closes (via [[AI Integration]]) +- Extension badge shows unread count + +## Aggregation rules (avoid spam) +- Group multiple votes: "5 people voted on 'Logo v3'" instead of 5 separate notifications +- Debounce: wait 1 minute before sending vote notification (batch) +- Don't notify for own actions +- Max 1 milestone notification per threshold (10, 25, 50, 100) + +## Notification channels summary + +| Channel | Phase | Description | +|---------|-------|-------------| +| In-app bell | MVP | Bell icon in navbar with dropdown | +| Email digest | Phase 3 | Daily/weekly summary email | +| Browser push | Phase 3 | Web Push API notifications | +| Slack | Phase 3 | Webhook + interactive messages (see [[Slack Integration]]) | +| Browser extension | Phase 3 | Badge with unread count | +| Figma plugin | Phase 3 | Notification dot in plugin panel | + +## Extension + Plugin tie-in +- Browser extension badge shows unread count +- Figma plugin shows notification dot +- Slack channel receives formatted notification cards +- All channels link back to pejla.io notification center diff --git a/docs/Pejla - Overview.md b/docs/Pejla - Overview.md new file mode 100644 index 000000000..acc97c076 --- /dev/null +++ b/docs/Pejla - Overview.md @@ -0,0 +1,35 @@ +# Pejla + +**Domain:** pejla.io +**Tagline:** Design feedback, without the noise. + +Pejla is a visual voting platform for designers and creative teams. Share design options, collect votes, and let the best design win — in minutes instead of endless Slack threads. + +## What it does + +1. **Share** — Upload images, video, audio, or paste a Figma/YouTube/CodePen link. Compare iterations side by side (v1 vs v2 vs v3). +2. **Vote** — Share a link. Anyone can vote with one tap, on any device. No sign-up needed (anonymous voting supported). +3. **Decide** — See results instantly with percentages, comments, and a clear winner. + +## Why it exists + +Design feedback is broken: +- "I prefer the first one" is not feedback +- "Can you just make it pop more?" — what does that even mean? +- "Let's circle back on the design" — 17 Slack replies later, still no decision + +Pejla turns subjective opinions into structured, votable decisions. + +## Links + +- [[Architecture]] +- [[Features]] +- [[Target Audience]] +- [[Tech Stack]] +- [[API Reference]] +- [[Pricing Model]] +- [[Beta Launch Plan]] +- [[Stripe Integration]] +- [[Figma Plugin]] +- [[Browser Extension]] +- [[Notifications]] diff --git a/docs/Pricing Model.md b/docs/Pricing Model.md new file mode 100644 index 000000000..fca61bb60 --- /dev/null +++ b/docs/Pricing Model.md @@ -0,0 +1,74 @@ +# Pricing Model + +## Philosophy + +Free to start, pay when you need team features. The core voting experience should always be free — that's how you get adoption. Money comes from teams who use it daily. + +Your friend at Qasa said it: "hade kunnat anvant internt." That's the signal — **teams paying monthly** is the model. + +## Tiers + +### Free (always free) +- Unlimited public polls +- Up to 5 active polls at a time +- Image/video/audio uploads (50MB limit) +- Embed support (Figma, YouTube, etc.) +- Anonymous voting +- Share links +- Comments +- Basic results (votes + percentages) + +### Pro — ~79 SEK/mo (~$8/mo) +- Unlimited active polls +- Unlisted + private polls +- Password-protected polls +- Poll deadlines +- Upload limit: 500MB +- Remove "Made with Pejla" watermark +- Custom share slug (`pejla.io/your-name/poll-title`) +- Export results as CSV/PDF +- Priority support + +### Team — ~249 SEK/mo (~$25/mo, up to 10 seats) +- Everything in Pro +- Team workspace +- Projects (group polls) +- Team member roles (admin/editor/viewer) +- Invite via link +- Team activity feed +- Shared asset library +- +29 SEK/mo per extra seat + +### Enterprise (contact sales) +- SSO / SAML +- Custom domain +- API access +- SLA +- Dedicated support +- Audit log +- On-prem option (maybe) + +## Why this works + +| Tier | Target | Revenue driver | +|------|--------|---------------| +| Free | Individual designers, students | Adoption + word of mouth | +| Pro | Freelancers, consultants | Privacy + branding | +| Team | Agencies, in-house teams (like Qasa) | Collaboration | +| Enterprise | Large orgs | High ACV | + +## Freemium conversion triggers +- Hit 5 poll limit -> "Upgrade to Pro for unlimited" +- Try to set password on free -> "Pro feature" +- Invite team member -> "Start a Team plan" +- Results page: "Export as PDF" grayed out -> "Pro feature" + +## Revenue math (conservative) +- 1,000 free users (costs ~$0) +- 5% convert to Pro = 50 x $8 = $400/mo +- 2% convert to Team = 20 x $25 = $500/mo +- Total: ~$900/mo at 1,000 users +- At 10,000 users: ~$9,000/mo + +## Implementation +See [[Stripe Integration]] diff --git a/docs/Roadmap.md b/docs/Roadmap.md new file mode 100644 index 000000000..a7926b230 --- /dev/null +++ b/docs/Roadmap.md @@ -0,0 +1,79 @@ +# Roadmap + +## Done + +### Phase 1 — Core platform (complete) +- [x] Poll CRUD (create, read, update, delete) +- [x] 14 REST API endpoints +- [x] Multi-media support (image, video, audio, embeds, text, files, URLs) +- [x] Inline text content (`textContent` field) + "Write text" button +- [x] Cover/thumbnail upload for non-visual options +- [x] Authenticated + anonymous voting +- [x] Password-protected polls with owner bypass +- [x] Visibility: public / unlisted / private +- [x] Comments with optional images +- [x] Remix (fork polls with all content types) +- [x] Deep-link per option: `/poll/:shareId/option/N` +- [x] Media stop on slide change +- [x] Upload progress + cancel (XHR with abort) +- [x] Notifications MVP (model, API, bell component, polling) +- [x] Cloudinary middleware (auto-routes image/video/audio/raw, SVG+GIF skip resize) +- [x] PWA (favicon.svg, apple-touch-icon, web manifest) +- [x] Storybook (Button, Card, Input, AuthModal) + +### Phase 1.5 — Polish (complete) +- [x] Custom Figma-style cursor (light/dark) +- [x] Apple-style subtle scrollbar +- [x] Paper-style markdown rendering on vote page +- [x] See-through edit panel +- [x] Smart thumbnails (text preview, audio icon, covers) +- [x] Landing copy updated for broader audience (videos, briefs, tracks) +- [x] "Project briefs" added to rotating words +- [x] Reduced motion support + +## Next up + +### Phase 2 — Pre-launch prep +- [ ] Lighthouse audit: aim for 90+ on all metrics +- [ ] Accessibility audit (WCAG 2.1 AA) +- [ ] Custom 404 page +- [ ] Error boundary with friendly message +- [ ] Loading skeletons on all pages +- [ ] Mobile responsive audit +- [ ] Meta tags + OG image for social sharing +- [ ] Remove bootcamp references from README/footer +- [ ] Rate limiting on API +- [ ] Input sanitization audit +- [ ] Demo content (5-8 high-quality example polls) + +### Phase 3 — Monetization +- [ ] [[Stripe Integration]] — Free / Pro / Team tiers +- [ ] Usage limits on free tier +- [ ] Upgrade prompts in UI + +### Phase 4 — Integrations +- [ ] [[Figma Plugin]] — export frames to Pejla polls +- [ ] [[Browser Extension]] — capture current page/URL +- [ ] [[Slack Integration]] — incoming webhook for results +- [ ] [[AI Integration]] — vote summaries, smart tagging + +### Phase 5 — Scale +- [ ] Real-time notifications (WebSocket or SSE) +- [ ] Teams & Projects UI +- [ ] Analytics dashboard (PostHog/Plausible) +- [ ] Admin panel improvements +- [ ] Custom branding per team +- [ ] API for external integrations + +## Timeline + +See [[Beta Launch Plan]] for detailed week-by-week plan. + +| Phase | Focus | Status | +|-------|-------|--------| +| 1 | Core platform | Done | +| 1.5 | Polish | Done | +| 2 | Pre-launch prep | Next | +| 3 | Monetization (Stripe) | Planned | +| 4 | Integrations | Planned | +| 5 | Scale | Future | diff --git a/docs/Slack Integration.md b/docs/Slack Integration.md new file mode 100644 index 000000000..60be76fce --- /dev/null +++ b/docs/Slack Integration.md @@ -0,0 +1,192 @@ +# Slack Integration + +**Status:** Planned +**Priority:** High — teams already discuss designs in Slack, meet them there + +## Why + +Design feedback conversations already happen in Slack. Messages like "which version do you prefer?" get buried in threads. Pejla's Slack integration turns those conversations into structured polls — without leaving Slack. + +## MVP scope (Incoming Webhook) + +The simplest version: post poll results to a Slack channel via incoming webhook. + +### Setup flow +1. User goes to Pejla settings -> Integrations -> Slack +2. Clicks "Add to Slack" (incoming webhook OAuth) +3. Selects a channel (e.g. `#design-feedback`) +4. Pejla stores the webhook URL per user/team + +### What gets posted + +When a poll closes or hits a vote milestone, Pejla sends a formatted message: + +``` ++------------------------------------------+ +| Pejla: "Logo v3" results | +|------------------------------------------| +| Option A: ████████░░ 8 votes (53%) | +| Option B: █████░░░░░ 5 votes (33%) | +| Option C: ██░░░░░░░░ 2 votes (13%) | +| | +| 15 total votes - 4 comments | +| [View full results on Pejla] | ++------------------------------------------+ +``` + +### Webhook notifications +| Event | Slack message | +|-------|--------------| +| Vote received | "New vote on 'Logo v3' - Option A is leading (8 votes)" | +| Vote milestone | "'Logo v3' reached 25 votes!" | +| Poll closed | Results summary card (see above) | +| New comment | "@mia commented on 'Logo v3': 'Top left feels heavy'" | + +### Technical approach (MVP) +```javascript +// In vote/comment endpoints, after saving: +const webhook = await SlackWebhook.findOne({ team: poll.team }); +if (webhook) { + await fetch(webhook.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + blocks: buildSlackBlocks(poll, event) + }) + }); +} +``` + +### Data model +```javascript +const slackWebhookSchema = new mongoose.Schema({ + user: { type: ObjectId, ref: "User", required: true }, + team: { type: ObjectId, ref: "Team" }, + webhookUrl: { type: String, required: true }, + channel: String, + channelId: String, + events: { + vote: { type: Boolean, default: true }, + voteMilestone: { type: Boolean, default: true }, + pollClosed: { type: Boolean, default: true }, + comment: { type: Boolean, default: true } + }, + createdAt: { type: Date, default: Date.now } +}); +``` + +--- + +## Phase 2: Full Slack App + +### Slash commands + +#### `/pejla create` +Create a poll directly from Slack. + +``` +/pejla create "Which logo?" option1.png option2.png option3.png +``` + +Opens a Slack modal: +``` ++----------------------------------+ +| Create Pejla Poll | +|----------------------------------| +| Title: [Which logo?] | +| | +| Options: | +| 1. [Upload or paste URL] | +| 2. [Upload or paste URL] | +| [+ Add option] | +| | +| Settings: | +| [ ] Anonymous voting | +| [ ] Set deadline | +| Channel: #design-feedback | +| | +| [Create] [Cancel] | ++----------------------------------+ +``` + +After creation, posts an interactive card to the channel with vote buttons. + +#### `/pejla results [shareId]` +Show poll results inline in Slack. + +``` +/pejla results abc123 +``` + +Returns an ephemeral message (only visible to you) with the current vote breakdown. + +### Interactive voting +When a poll is posted to Slack, team members can vote with buttons — no need to open pejla.io: + +``` ++------------------------------------------+ +| Logo v3 - Vote now! | +| by @daniel | +|------------------------------------------| +| [image thumbnails] | +| | +| [Vote A] [Vote B] [Vote C] | +| | +| 3 votes so far | View on Pejla | ++------------------------------------------+ +``` + +### Message action: "Create Pejla poll from this thread" +- Right-click a Slack message -> "Create Pejla poll" +- Extracts images/links from the thread +- Pre-fills poll options from attachments +- Great for turning messy threads into structured votes + +### Link unfurling +When someone pastes a `pejla.io/poll/abc123` link in Slack, it unfurls into a rich preview card: + +``` ++------------------------------------------+ +| pejla.io | +| Logo v3 - Design Vote | +| [thumbnail] 8 votes | 4 comments | +| Option A leading (53%) | +| [Vote now] | ++------------------------------------------+ +``` + +### OAuth2 flow +1. User clicks "Add to Slack" on pejla.io +2. Redirects to Slack OAuth: `https://slack.com/oauth/v2/authorize?scope=commands,chat:write,links:read,links:write&client_id=...` +3. User approves, Slack redirects back with code +4. Pejla exchanges code for access token +5. Store token + team info in database + +### Required Slack scopes +| Scope | Why | +|-------|-----| +| `commands` | Slash commands `/pejla` | +| `chat:write` | Post messages to channels | +| `links:read` | Detect pejla.io links for unfurling | +| `links:write` | Post unfurl cards | +| `incoming-webhook` | MVP webhook posting | +| `users:read` | Map Slack users to Pejla users | + +## API endpoints needed + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/integrations/slack/install` | Start OAuth2 flow | +| GET | `/integrations/slack/callback` | OAuth2 callback | +| POST | `/integrations/slack/commands` | Slash command handler | +| POST | `/integrations/slack/interactions` | Button clicks, modal submissions | +| POST | `/integrations/slack/events` | Event subscriptions (link unfurling) | +| DELETE | `/integrations/slack` | Disconnect Slack | + +## Ties into + +- [[Notifications]] — Slack becomes a notification channel alongside in-app and email +- [[Figma Plugin]] — Plugin can show which Slack channel a poll was posted to +- [[Browser Extension]] — Extension can send results to a connected Slack channel +- [[Beta Launch Plan]] — Slack integration is a launch differentiator for design teams +- [[AI Integration]] — AI vote summaries can be posted to Slack automatically diff --git a/docs/Stripe Integration.md b/docs/Stripe Integration.md new file mode 100644 index 000000000..adb696039 --- /dev/null +++ b/docs/Stripe Integration.md @@ -0,0 +1,144 @@ +# Stripe Integration + +**Status:** Planned +**Priority:** Medium — need this before real launch, not for beta + +## Approach: Stripe Checkout + Customer Portal + +Don't build billing UI from scratch. Stripe Checkout handles payment, Stripe Portal handles subscription management. You only need: +1. A "Subscribe" button that redirects to Stripe Checkout +2. A webhook endpoint that activates the plan +3. A "Manage subscription" link to Stripe Portal + +## Setup steps + +### 1. Stripe account +- Create account at stripe.com +- Set up products + prices in Stripe Dashboard: + - `pro_monthly` — 79 SEK/mo + - `team_monthly` — 249 SEK/mo (base, 10 seats) + - `team_seat` — 29 SEK/mo (metered, per extra seat) + +### 2. Backend changes + +#### New fields on User model +```javascript +// Add to User schema +stripeCustomerId: { type: String, default: "" }, +plan: { + type: String, + enum: ["free", "pro", "team", "enterprise"], + default: "free" +}, +planExpiresAt: { type: Date, default: null }, +teamSeats: { type: Number, default: 0 } +``` + +#### New endpoints +``` +POST /billing/checkout # Create Stripe Checkout session +POST /billing/portal # Create Stripe Portal session +POST /billing/webhook # Stripe webhook (signature verified) +GET /billing/status # Current plan info +``` + +#### Checkout endpoint +```javascript +import Stripe from "stripe"; +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + +app.post("/billing/checkout", authenticateUser, async (req, res) => { + const { priceId } = req.body; // "pro_monthly" or "team_monthly" + + // Create or reuse Stripe customer + let customerId = req.user.stripeCustomerId; + if (!customerId) { + const customer = await stripe.customers.create({ + email: req.user.email, + metadata: { userId: req.user._id.toString() } + }); + customerId = customer.id; + req.user.stripeCustomerId = customerId; + await req.user.save(); + } + + const session = await stripe.checkout.sessions.create({ + customer: customerId, + mode: "subscription", + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${process.env.FRONTEND_URL}/settings?billing=success`, + cancel_url: `${process.env.FRONTEND_URL}/settings?billing=cancel`, + }); + + res.json({ url: session.url }); +}); +``` + +#### Webhook endpoint +```javascript +app.post("/billing/webhook", + express.raw({ type: "application/json" }), + async (req, res) => { + const sig = req.headers["stripe-signature"]; + const event = stripe.webhooks.constructEvent( + req.body, sig, process.env.STRIPE_WEBHOOK_SECRET + ); + + switch (event.type) { + case "checkout.session.completed": + // Activate plan + break; + case "customer.subscription.updated": + // Plan change (upgrade/downgrade) + break; + case "customer.subscription.deleted": + // Downgrade to free + break; + case "invoice.payment_failed": + // Grace period / notify user + break; + } + + res.json({ received: true }); + } +); +``` + +### 3. Frontend changes + +#### Pricing page +- Simple comparison table (Free vs Pro vs Team) +- "Get started" -> register (free) +- "Upgrade to Pro" -> POST `/billing/checkout` -> redirect to Stripe +- "Start Team plan" -> same flow + +#### Plan gating +```typescript +// In pollStore or a separate billingStore +const canCreatePoll = user.plan !== "free" || userPollCount < 5; +const canSetPassword = user.plan !== "free"; +const canInviteTeam = user.plan === "team" || user.plan === "enterprise"; +``` + +#### Settings page +- Show current plan +- "Manage subscription" -> POST `/billing/portal` -> redirect to Stripe Portal +- Stripe Portal handles: cancel, update card, change plan, invoices + +### 4. Environment variables +``` +STRIPE_SECRET_KEY=sk_live_... +STRIPE_PUBLISHABLE_KEY=pk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PRO_PRICE_ID=price_... +STRIPE_TEAM_PRICE_ID=price_... +``` + +## Testing +- Use Stripe test mode (`sk_test_...`) +- Test cards: `4242 4242 4242 4242` (success), `4000 0000 0000 0002` (decline) +- Use Stripe CLI for local webhook testing: `stripe listen --forward-to localhost:8080/billing/webhook` + +## Dependencies +- `stripe` npm package (backend) +- No frontend Stripe SDK needed (Checkout is a redirect) diff --git a/docs/Target Audience.md b/docs/Target Audience.md new file mode 100644 index 000000000..f8a7cfa38 --- /dev/null +++ b/docs/Target Audience.md @@ -0,0 +1,68 @@ +# Target Audience + +## Primary: Designers + +### Individual designers / freelancers +- Share design iterations with clients +- Get structured feedback instead of "make it pop" +- Quick A/B testing of variants +- **Pain:** Client feedback is vague, scattered across email/Slack/meetings +- **Pejla solves:** One link, clear vote, decision made + +### Design teams (in-house) +- Design crits without the politics +- Compare component variants, color schemes, layout options +- Async feedback across time zones +- **Pain:** Design reviews take forever, loudest voice wins +- **Pejla solves:** Everyone votes equally, data speaks +- **Example:** "Du borde gora en grej av pejla, hade ju kunnat anvant internt pa Qasa" — friend at Qasa (Swedish proptech) + +### Design students / bootcampers +- Share portfolio pieces for peer feedback +- Practice giving/receiving design feedback +- **Pain:** Hard to get honest feedback from classmates +- **Pejla solves:** Anonymous voting = honest opinions + +## Secondary: Product teams + +### Product managers +- Feature prioritization polls +- Stakeholder alignment on UI direction +- **Pain:** Endless meetings to align on visual direction + +### Developers +- Compare implementation approaches (screenshots, prototypes) +- Vote on component library options +- Code review visual diffs + +### Marketing teams +- A/B test ad creatives, landing page variants, brand assets +- Get team consensus before launch + +## Tertiary: Content creators + +- YouTubers choosing thumbnails +- Streamers picking overlays/emotes +- Podcasters choosing cover art + +## Company size sweet spots + +| Size | Use case | +|------|----------| +| Solo / freelance | Client feedback loops | +| 2-10 (startup/agency) | Internal design decisions | +| 10-50 (scale-up) | Cross-team alignment | +| 50+ (enterprise) | Design system governance, brand consistency | + +## Geographic focus +- Start with Nordic/European design community +- English-first UI, Swedish founder story +- Design Twitter/X, Dribbble, Figma community as distribution channels + +## Competitor landscape +- **UsabilityHub / Lyssna** — more research-focused, expensive +- **Poll Everywhere** — presentations, not visual +- **Maze** — usability testing, not visual voting +- **Slack polls** — no visual comparison, buried in threads +- **Google Forms** — no media support, ugly +- **Pejla advantage:** Visual-first, zero friction, embeds everything, free tier diff --git a/docs/Tech Stack.md b/docs/Tech Stack.md new file mode 100644 index 000000000..65a75ad11 --- /dev/null +++ b/docs/Tech Stack.md @@ -0,0 +1,45 @@ +# Tech Stack + +## Frontend +| Tech | Why | +|------|-----| +| **Vite** | Fast dev server, HMR, optimized builds | +| **React 18** | Component model, hooks, ecosystem | +| **TypeScript** | Type safety, better DX | +| **Tailwind v4** | Utility-first CSS, zero runtime | +| **shadcn/ui** | Accessible, customizable components (Button, Card, Input, Badge, Skeleton, etc.) | +| **Zustand** | Lightweight state management (pollStore) | +| **React Router** | Client-side routing | +| **react-markdown** | Render .md/.txt files inline | +| **Storybook** | Component development + documentation | +| **Lucide React** | Icon library | + +## Backend +| Tech | Why | +|------|-----| +| **Express.js** | Simple, well-known Node framework | +| **Mongoose** | MongoDB ODM with schema validation | +| **bcrypt** | Password hashing | +| **crypto** | UUID generation for tokens + shareIds | +| **dotenv** | Environment variable management | +| **cors** | Cross-origin resource sharing | + +## Infrastructure +| Service | Purpose | +|---------|---------| +| **MongoDB Atlas** | Cloud database (free tier) | +| **Cloudinary** | File CDN — images, video, audio, raw files | +| **Render** | Backend hosting | +| **Vercel** (planned) | Frontend hosting | + +## Dev tools +- **Vitest** — unit tests (embedUrl tests) +- **Postman** — API testing +- **Lighthouse** — performance/accessibility audits (target: 100) + +## Key technical decisions +- **XHR over fetch for uploads** — fetch doesn't support upload progress events +- **Access token auth** — simple UUID tokens instead of JWT (good enough for MVP, swap to JWT later) +- **Cloudinary middleware** — auto-routes by mimetype, SVG/GIF skip resize +- **localStorage fingerprint** — anonymous voting without accounts +- **No SSR** — SPA is fine for MVP, can add later if SEO matters diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 000000000..78cf7b392 --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,17 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + "stories": [ + "../src/**/*.mdx", + "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" + ], + "addons": [ + "@chromatic-com/storybook", + "@storybook/addon-vitest", + "@storybook/addon-a11y", + "@storybook/addon-docs", + "@storybook/addon-onboarding" + ], + "framework": "@storybook/react-vite" +}; +export default config; \ No newline at end of file diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts new file mode 100644 index 000000000..340961be8 --- /dev/null +++ b/frontend/.storybook/preview.ts @@ -0,0 +1,22 @@ +import type { Preview } from '@storybook/react-vite' +import '../src/index.css' + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo' + } + }, +}; + +export default preview; \ No newline at end of file diff --git a/frontend/.storybook/vitest.setup.ts b/frontend/.storybook/vitest.setup.ts new file mode 100644 index 000000000..44922d55e --- /dev/null +++ b/frontend/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; +import { setProjectAnnotations } from '@storybook/react-vite'; +import * as projectAnnotations from './preview'; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index 5cdb1d9cf..7c8143ebc 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,55 @@ -# Frontend part of Final Project +# Pejla Frontend -This boilerplate is designed to give you a head start in your React projects, with a focus on understanding the structure and components. As a student of Technigo, you'll find this guide helpful in navigating and utilizing the repository. +React + TypeScript SPA for the Pejla platform. -## Getting Started +## Setup -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +```bash +npm install +cp .env.example .env # set VITE_API_URL +npm run dev +``` + +## Scripts + +| Command | Description | +|---------|-------------| +| `npm run dev` | Start dev server (Vite) | +| `npm run build` | Production build | +| `npm run preview` | Preview production build | +| `npm test` | Run unit tests (Vitest) | +| `npm run storybook` | Start Storybook | + +## Project structure + +``` +src/ + api/ — API client (polls, auth) + assets/ — static assets + components/ — reusable components (Header, Comments, AuthModal) + components/ui — shadcn/ui primitives (Button, Card, Input, etc.) + context/ — AuthContext (login state) + hooks/ — custom hooks (useDebounce, useMediaQuery) + pages/ — route pages (Home, Dashboard, CreatePoll, VotePoll, etc.) + stores/ — Zustand store (pollStore) + utils/ — utilities (embedUrl parser) +``` + +## Key pages + +- **Home** — public poll feed with search +- **Dashboard** — logged-in user's poll management +- **CreatePoll** — form with media upload, embed URLs +- **VotePoll** — fullscreen swipe carousel for voting +- **Results** — vote breakdown with progress bars +- **Profile** — user stats, polls list, account settings + +## Tech + +- **React 18** + TypeScript +- **React Router 7** — client-side routing +- **Zustand** — global poll state +- **Tailwind CSS 4** + **shadcn/ui** — styling and UI components +- **Framer Motion** — animations +- **Vitest** — unit testing +- **Storybook** — component development diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 000000000..c3085d6c2 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/frontend/index.html b/frontend/index.html index 664410b5b..07b23b8db 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,29 @@ <html lang="en"> <head> <meta charset="UTF-8" /> - <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> + <link rel="manifest" href="/site.webmanifest" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Technigo React Vite Boiler Plate + + + Pejla — Design feedback, without the noise + + + + + + + + + + + + + + + +
diff --git a/frontend/package.json b/frontend/package.json index 7b2747e94..ddcca6be5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,20 +7,64 @@ "dev": "vite", "build": "vite build", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "test": "vitest run --project unit", + "test:watch": "vitest --project unit", + "preview": "vite preview", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "dependencies": { + "@tailwindcss/typography": "^0.5.19", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.34.2", + "lucide-react": "^0.575.0", + "next-themes": "^0.4.6", + "posthog-js": "^1.360.2", + "radix-ui": "^1.4.3", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-dropzone": "^15.0.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.13.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.11" }, "devDependencies": { + "@chromatic-com/storybook": "^5.0.1", + "@storybook/addon-a11y": "^10.2.10", + "@storybook/addon-docs": "^10.2.10", + "@storybook/addon-onboarding": "^10.2.10", + "@storybook/addon-vitest": "^10.2.10", + "@storybook/react-vite": "^10.2.10", + "@tailwindcss/vite": "^4.2.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/node": "^25.3.0", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", "@vitejs/plugin-react": "^4.0.3", + "@vitest/browser-playwright": "^4.0.18", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^8.45.0", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", - "vite": "^6.3.5" + "eslint-plugin-storybook": "^10.2.10", + "jsdom": "^28.1.0", + "playwright": "^1.58.2", + "shadcn": "^3.8.5", + "storybook": "^10.2.10", + "tailwindcss": "^4.2.0", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", + "vite": "^6.3.5", + "vitest": "^4.0.18" + }, + "eslintConfig": { + "extends": [ + "plugin:storybook/recommended" + ] } } diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 000000000..ad37e2c2c --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/frontend/public/apercu-font/apercu-bold-italic.woff2 b/frontend/public/apercu-font/apercu-bold-italic.woff2 new file mode 100644 index 000000000..0ec3d17f9 Binary files /dev/null and b/frontend/public/apercu-font/apercu-bold-italic.woff2 differ diff --git a/frontend/public/apercu-font/apercu-bold.woff2 b/frontend/public/apercu-font/apercu-bold.woff2 new file mode 100644 index 000000000..3ec0923e6 Binary files /dev/null and b/frontend/public/apercu-font/apercu-bold.woff2 differ diff --git a/frontend/public/apercu-font/apercu-font.css b/frontend/public/apercu-font/apercu-font.css new file mode 100644 index 000000000..47454b199 --- /dev/null +++ b/frontend/public/apercu-font/apercu-font.css @@ -0,0 +1,42 @@ +/* Apercu - Downloaded from belleduffner.com/playerpursuits */ +/* Original typeface by Colophon Foundry (https://www.colophon-foundry.org/typefaces/apercu) */ + +@font-face { + font-family: "Apercu"; + src: url("./apercu-regular.woff2") format("woff2"); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "Apercu"; + src: url("./apercu-medium.woff2") format("woff2"); + font-style: normal; + font-weight: 500; + font-display: swap; +} + +@font-face { + font-family: "Apercu"; + src: url("./apercu-bold.woff2") format("woff2"); + font-style: normal; + font-weight: 700; + font-display: swap; +} + +@font-face { + font-family: "Apercu"; + src: url("./apercu-italic.woff2") format("woff2"); + font-style: italic; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "Apercu"; + src: url("./apercu-bold-italic.woff2") format("woff2"); + font-style: italic; + font-weight: 700; + font-display: swap; +} diff --git a/frontend/public/apercu-font/apercu-italic.woff2 b/frontend/public/apercu-font/apercu-italic.woff2 new file mode 100644 index 000000000..03821c70d Binary files /dev/null and b/frontend/public/apercu-font/apercu-italic.woff2 differ diff --git a/frontend/public/apercu-font/apercu-medium.woff2 b/frontend/public/apercu-font/apercu-medium.woff2 new file mode 100644 index 000000000..b2194798e Binary files /dev/null and b/frontend/public/apercu-font/apercu-medium.woff2 differ diff --git a/frontend/public/apercu-font/apercu-pro-light.woff2 b/frontend/public/apercu-font/apercu-pro-light.woff2 new file mode 100644 index 000000000..17e7d4b03 --- /dev/null +++ b/frontend/public/apercu-font/apercu-pro-light.woff2 @@ -0,0 +1,385 @@ + + + +Web fonts catalog | FontsHub.pro + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/apercu-font/apercu-pro-medium-italic.woff2 b/frontend/public/apercu-font/apercu-pro-medium-italic.woff2 new file mode 100644 index 000000000..549d88715 Binary files /dev/null and b/frontend/public/apercu-font/apercu-pro-medium-italic.woff2 differ diff --git a/frontend/public/apercu-font/apercu-pro-mono.woff2 b/frontend/public/apercu-font/apercu-pro-mono.woff2 new file mode 100644 index 000000000..bef5cc279 Binary files /dev/null and b/frontend/public/apercu-font/apercu-pro-mono.woff2 differ diff --git a/frontend/public/apercu-font/apercu-regular.woff2 b/frontend/public/apercu-font/apercu-regular.woff2 new file mode 100644 index 000000000..46aba5cd9 Binary files /dev/null and b/frontend/public/apercu-font/apercu-regular.woff2 differ diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 000000000..aff95c99f Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/exposure-font/exposure-font.css b/frontend/public/exposure-font/exposure-font.css new file mode 100644 index 000000000..4a3cc9c31 --- /dev/null +++ b/frontend/public/exposure-font/exposure-font.css @@ -0,0 +1,27 @@ +/* Exposure Trial - Downloaded from belleduffner.com/playerpursuits */ +/* Original typeface by Federico Parra Barrios (205.tf) */ +/* NOTE: These are TRIAL versions - purchase full license at https://www.205.tf/exposure */ + +@font-face { + font-family: "Exposure Trial"; + src: url("./exposure-trial-minus10.woff2") format("woff2"); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "Exposure Trial Light"; + src: url("./exposure-trial-minus20.woff2") format("woff2"); + font-style: normal; + font-weight: 300; + font-display: swap; +} + +@font-face { + font-family: "Exposure Trial"; + src: url("./exposure-italic-trial-minus20.woff2") format("woff2"); + font-style: italic; + font-weight: 300; + font-display: swap; +} diff --git a/frontend/public/exposure-font/exposure-italic-trial-minus20.woff2 b/frontend/public/exposure-font/exposure-italic-trial-minus20.woff2 new file mode 100644 index 000000000..e42bc714b Binary files /dev/null and b/frontend/public/exposure-font/exposure-italic-trial-minus20.woff2 differ diff --git a/frontend/public/exposure-font/exposure-trial-minus10.woff2 b/frontend/public/exposure-font/exposure-trial-minus10.woff2 new file mode 100644 index 000000000..3df198d9b Binary files /dev/null and b/frontend/public/exposure-font/exposure-trial-minus10.woff2 differ diff --git a/frontend/public/exposure-font/exposure-trial-minus20.woff2 b/frontend/public/exposure-font/exposure-trial-minus20.woff2 new file mode 100644 index 000000000..a4b1aa802 Binary files /dev/null and b/frontend/public/exposure-font/exposure-trial-minus20.woff2 differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 000000000..d0ed8ba80 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/how/01.png b/frontend/public/how/01.png new file mode 100644 index 000000000..1242ebfc2 Binary files /dev/null and b/frontend/public/how/01.png differ diff --git a/frontend/public/how/01@2x.png b/frontend/public/how/01@2x.png new file mode 100644 index 000000000..c8b723fb5 Binary files /dev/null and b/frontend/public/how/01@2x.png differ diff --git a/frontend/public/how/02.png b/frontend/public/how/02.png new file mode 100644 index 000000000..b5a59cfe6 Binary files /dev/null and b/frontend/public/how/02.png differ diff --git a/frontend/public/how/02@2x.png b/frontend/public/how/02@2x.png new file mode 100644 index 000000000..6411a2e4a Binary files /dev/null and b/frontend/public/how/02@2x.png differ diff --git a/frontend/public/how/3.png b/frontend/public/how/3.png new file mode 100644 index 000000000..91d9c73d5 Binary files /dev/null and b/frontend/public/how/3.png differ diff --git a/frontend/public/how/3@2x.png b/frontend/public/how/3@2x.png new file mode 100644 index 000000000..801362f84 Binary files /dev/null and b/frontend/public/how/3@2x.png differ diff --git a/frontend/public/how/4.png b/frontend/public/how/4.png new file mode 100644 index 000000000..ced056529 Binary files /dev/null and b/frontend/public/how/4.png differ diff --git a/frontend/public/how/4@2x.png b/frontend/public/how/4@2x.png new file mode 100644 index 000000000..21a28db18 Binary files /dev/null and b/frontend/public/how/4@2x.png differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 000000000..894d66c25 Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 000000000..97255c032 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 000000000..d0ed8ba80 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/og_pejla.png b/frontend/public/og_pejla.png new file mode 100644 index 000000000..e70311000 Binary files /dev/null and b/frontend/public/og_pejla.png differ diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 000000000..df29720af --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://pejla.io/sitemap.xml diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 000000000..08a923075 --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1,12 @@ +{ + "name": "Pejla", + "short_name": "Pejla", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } + ], + "theme_color": "#000000", + "background_color": "#000000", + "display": "standalone", + "start_url": "/" +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb1b..000000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6..b290c28d6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,81 @@ -export const App = () => { +import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; +import { useEffect } from "react"; +import { posthog } from "./lib/analytics"; +import { AuthProvider } from "./context/AuthContext"; +import { ThemeProvider } from "./components/ThemeProvider"; +import { Toaster } from "./components/ui/sonner"; +import Header from "./components/Header"; +import Footer from "./components/Footer"; +import CookieConsent from "./components/CookieConsent"; +import Home from "./pages/Home"; +import Dashboard from "./pages/Dashboard"; +import CreatePoll from "./pages/CreatePoll"; +import VotePoll from "./pages/VotePoll"; +import Results from "./pages/Results"; +import EditPoll from "./pages/EditPoll"; +import Profile from "./pages/Profile"; +import About from "./pages/About"; +import Explore from "./pages/Explore"; +import Landing from "./pages/Landing"; + +const AppRoutes = () => { + const location = useLocation(); + const bgLocation = location.state?.backgroundLocation; + + // Scroll to top + track pageview on route change + useEffect(() => { + window.scrollTo(0, 0); + posthog.capture("$pageview"); + }, [location.pathname]); return ( <> -

Welcome to Final Project!

+ + Skip to content + +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+