diff --git a/bounty-3/DESIGN.md b/bounty-3/DESIGN.md new file mode 100644 index 00000000..c0ffd988 --- /dev/null +++ b/bounty-3/DESIGN.md @@ -0,0 +1,81 @@ +# Warpspeed Bounty #3 — Poll Creation & Voting UI — Design + +## Architecture Overview + +``` +Chat UI + └─ Action Menu ("+") ──> PollCreationSheet + │ + PollStore.createPoll() + │ + Chat Message (InlinePoll) + │ + ┌───────────┴───────────┐ + PollVoting PollResults + (not voted) (voted) + │ + ViewVotesSheet +``` + +The poll feature integrates directly into the group chat message list. Each poll is rendered as an `InlinePoll` card. Before the current user votes, the card shows `PollVoting` (interactive options). After voting, it swaps to `PollResults` (read-only bars with percentages). A "View Votes" button opens the `ViewVotesSheet` bottom sheet listing every voter grouped by option. + +## Component Responsibilities + +| Component | Role | +|---|---| +| `PollCreationSheet` | Bottom sheet modal — question input, dynamic option list (2–12), single/multi toggle, create action. | +| `InlinePoll` | Container card in the chat — header badge, question, delegates to `PollVoting` or `PollResults`. | +| `PollVoting` | Radio buttons (single) or checkboxes (multi) — selection state, submit vote. | +| `PollResults` | Horizontal result bars, percentages, vote counts, leading-option crown, "View Votes" trigger. | +| `ViewVotesSheet` | Bottom sheet with `SectionList` — each option is a section, voters listed with avatar + timestamp. | + +## Data Flow + +1. User taps the "+" action button in chat input → `PollCreationSheet` opens. +2. User fills in question + options + toggles multi/single → taps "Create Poll". +3. `PollStore.createPoll()` is called → returns a `Poll` object stored in the MobX store. +4. A chat message containing the `InlinePoll` component is inserted into the message list. +5. User taps options → `PollVoting` manages local selection state → "Vote" button calls `PollStore.castVote()`. +6. `castVote` adds the user's `Vote` record to each selected option's `voters` array and sets `poll.userVote`. +7. Because `poll.userVote` becomes defined, `InlinePoll` automatically swaps to `PollResults`. +8. `PollResults` reads option voters via `getResults()` which computes percentages, leading option, etc. +9. "View Votes" fires a callback that renders `ViewVotesSheet` with the same data, grouped by option. + +## State Management (MobX) + +`PollStore` is a singleton MobX store with `makeAutoObservable`: + +- `polls: Map` — observable map of all polls in the session. +- `createPoll(data, createdBy)` — generates IDs, builds `PollOption[]`, stores poll. +- `castVote(pollId, optionIds, userId, username)` — validates, pushes `Vote` objects, sets `userVote`. +- `getResults(pollId)` — derived array of `PollResult` with percentages. +- `hasUserVoted(pollId)` — convenience getter. + +Votes are **immutable** once cast (one user = one vote). Multi-choice allows selecting multiple options in a single submission. + +## Styling Approach + +- Dark theme (matching Warpspeed's iOS aesthetic): `#1C1C1E` surfaces, `#2C2C2E` inputs, `#FFFFFF` text, `#8E8E93` secondary. +- Accent: `#0A84FF` (iOS blue) for selected states, buttons. +- 12-color palette (`POLL_COLORS`) for result bars — distinct enough to differentiate options. +- Bottom sheets use `borderTopLeftRadius: 16` / `borderTopRightRadius: 16` with a drag handle indicator. +- All touch targets are ≥44px height for accessibility. +- `SectionList` in `ViewVotesSheet` groups voters by option with a colored dot per section header. + +## Edge Cases + +- **Multi-choice toggle**: `PollCreationSheet` shows `Switch`; `PollVoting` renders checkboxes when true. +- **Empty poll vote**: "View Votes" shows "No votes yet" empty state. +- **255 char limit**: enforced on each option input; question limit of 500 chars. +- **Min 2 / Max 12 options**: creation sheet disables add/remove at boundaries. +- **Leading option**: `PollResults` shows a crown emoji next to the leading option(s); ties are handled (multiple leaders). +- **User's own vote**: highlighted with blue text + a blue dot indicator on the result bar. +- **Single choice re-selection**: tapping the same radio button deselects it (allows un-selection before submit). + +## Future Considerations + +- API integration: replace MobX store with socket events, persist polls server-side. +- Poll expiry: add `endsAt` timestamp + auto-close + "Poll closed" state. +- Edit poll: allow creator to add options before voting starts. +- Anonymous polls: hide usernames in results. +- Reactions: add emoji reactions on poll cards. diff --git a/bounty-3/README.md b/bounty-3/README.md new file mode 100644 index 00000000..bcf71525 --- /dev/null +++ b/bounty-3/README.md @@ -0,0 +1,76 @@ +# Warpspeed Bounty #3 — Group Chat Poll Creation & Voting UI + +**Bounty Amount:** $440 +**Category:** React Native / TypeScript / MobX +**Status:** ✅ Reference Implementation + +## Overview + +A complete, production-ready reference implementation of the Warpspeed Group Chat Poll Creation & Voting feature. Users can create polls directly from the chat input action menu, vote on polls, view real-time results with percentage bars, and inspect voter details per option in a bottom sheet. + +## Files + +| File | Purpose | +|---|---| +| `types.ts` | All TypeScript interfaces (`Poll`, `PollOption`, `Vote`, `PollResult`) and constants | +| `state/pollStore.ts` | MobX store with `createPoll`, `castVote`, `getResults` | +| `components/PollCreationSheet.tsx` | Bottom sheet for creating polls (question + 2–12 options, single/multi) | +| `components/InlinePoll.tsx` | Poll card rendered inline in chat messages | +| `components/PollVoting.tsx` | Interactive voting (radio / checkbox select, submit) | +| `components/PollResults.tsx` | Result bars with percentages, leading option, vote counts | +| `components/ViewVotesSheet.tsx` | Bottom sheet listing voters grouped by option | +| `DESIGN.md` | Architecture overview, component tree, data flow, styling, edge cases | + +## Usage + +```tsx +import { pollStore } from './state/pollStore'; +import { InlinePoll } from './components/InlinePoll'; +import { PollCreationSheet } from './components/PollCreationSheet'; + +// 1. Show creation sheet + setShowCreation(false)} + onCreate={(data) => { + const poll = pollStore.createPoll(data, currentUserId); + appendMessage({ type: 'poll', pollId: poll.id }); + }} +/> + +// 2. Render inline poll in chat + + pollStore.castVote(pollId, optionIds, currentUserId, currentUsername) + } + onViewVotes={(pollId) => setViewingPollId(pollId)} +/> + +// 3. View votes bottom sheet + setViewingPollId(null)} +/> +``` + +## Key Features + +- **Poll Creation**: Bottom sheet with question + 2–12 options (255 char limit each), single/multi-choice toggle +- **Inline Poll**: Chat-native card with question, options, voting controls +- **Direct Voting**: Radio buttons (single) / checkboxes (multi), submit vote +- **Real-Time Results**: Animated bars, percentages, vote counts, leading-option crown +- **View Votes**: SectionList grouped by option with voter avatars and timestamps +- **MobX State Management**: Observable store with auto-computed results +- **Dark Theme**: iOS-style dark mode design system + +## Dependencies + +- `react-native` (≥0.72) +- `mobx` (≥6.10) + `mobx-react-lite` +- TypeScript (≥5.0) + +No third-party UI libraries required — all components use React Native core primitives. diff --git a/bounty-3/components/InlinePoll.tsx b/bounty-3/components/InlinePoll.tsx new file mode 100644 index 00000000..e4369597 --- /dev/null +++ b/bounty-3/components/InlinePoll.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Poll } from '../types'; +import { PollVoting } from './PollVoting'; +import { PollResults } from './PollResults'; + +interface InlinePollProps { + poll: Poll; + currentUserId: string; + currentUsername: string; + onVote: (pollId: string, optionIds: string[]) => void; + onViewVotes: (pollId: string) => void; +} + +export const InlinePoll: React.FC = ({ + poll, + currentUserId, + currentUsername, + onVote, + onViewVotes, +}) => { + const hasVoted = poll.userVote !== undefined; + + return ( + + + + 📊 Poll + + + by {poll.createdBy} + + + + {poll.question} + + + {poll.isMultiChoice ? 'Select all that apply' : 'Select one option'} + + + {hasVoted ? ( + + ) : ( + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#1C1C1E', + borderRadius: 12, + padding: 16, + marginVertical: 8, + borderWidth: 1, + borderColor: '#2C2C2E', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + badge: { + backgroundColor: '#2C2C2E', + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 3, + marginRight: 8, + }, + badgeText: { + fontSize: 12, + color: '#FFFFFF', + fontWeight: '600', + }, + createdBy: { + fontSize: 12, + color: '#8E8E93', + }, + question: { + fontSize: 17, + fontWeight: '600', + color: '#FFFFFF', + marginBottom: 6, + lineHeight: 22, + }, + subtitle: { + fontSize: 13, + color: '#8E8E93', + marginBottom: 14, + }, +}); diff --git a/bounty-3/components/PollCreationSheet.tsx b/bounty-3/components/PollCreationSheet.tsx new file mode 100644 index 00000000..5f433802 --- /dev/null +++ b/bounty-3/components/PollCreationSheet.tsx @@ -0,0 +1,306 @@ +import React, { useState, useCallback } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + ScrollView, + Switch, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { + PollCreationData, + MAX_OPTIONS, + MIN_OPTIONS, + OPTION_MAX_LENGTH, + QUESTION_MAX_LENGTH, +} from '../types'; + +interface PollCreationSheetProps { + visible: boolean; + onClose: () => void; + onCreate: (data: PollCreationData) => void; +} + +export const PollCreationSheet: React.FC = ({ + visible, + onClose, + onCreate, +}) => { + const [question, setQuestion] = useState(''); + const [options, setOptions] = useState(['', '']); + const [isMultiChoice, setIsMultiChoice] = useState(false); + + const addOption = useCallback(() => { + if (options.length < MAX_OPTIONS) { + setOptions((prev) => [...prev, '']); + } + }, [options.length]); + + const removeOption = useCallback((index: number) => { + setOptions((prev) => { + if (prev.length <= MIN_OPTIONS) return prev; + return prev.filter((_, i) => i !== index); + }); + }, []); + + const updateOption = useCallback((index: number, text: string) => { + if (text.length > OPTION_MAX_LENGTH) return; + setOptions((prev) => { + const next = [...prev]; + next[index] = text; + return next; + }); + }, []); + + const canSubmit = + question.trim().length > 0 && + options.every((o) => o.trim().length > 0) && + options.length >= MIN_OPTIONS; + + const handleCreate = useCallback(() => { + if (!canSubmit) return; + onCreate({ + question: question.trim(), + options: options.map((o) => o.trim()).filter(Boolean), + isMultiChoice, + }); + setQuestion(''); + setOptions(['', '']); + setIsMultiChoice(false); + onClose(); + }, [canSubmit, question, options, isMultiChoice, onCreate, onClose]); + + if (!visible) return null; + + return ( + + + + + + Create Poll + + Question + + + + Options ({options.length}/{MAX_OPTIONS}) + + {options.map((option, index) => ( + + {index + 1}. + updateOption(index, t)} + maxLength={OPTION_MAX_LENGTH} + /> + {options.length > MIN_OPTIONS && ( + removeOption(index)} + style={styles.removeBtn} + > + + + )} + + ))} + + {options.length < MAX_OPTIONS && ( + + + Add Option + + )} + + + Allow multiple choices + + + + + + Cancel + + + Create Poll + + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + zIndex: 1000, + }, + container: { + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: '#1C1C1E', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: '85%', + }, + handle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#3A3A3C', + alignSelf: 'center', + marginTop: 8, + marginBottom: 8, + }, + scroll: { + paddingHorizontal: 20, + paddingBottom: 34, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: '#FFFFFF', + marginBottom: 20, + textAlign: 'center', + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#8E8E93', + marginBottom: 8, + marginTop: 12, + }, + questionInput: { + backgroundColor: '#2C2C2E', + borderRadius: 10, + padding: 14, + fontSize: 16, + color: '#FFFFFF', + minHeight: 48, + textAlignVertical: 'top', + }, + optionRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + optionIndex: { + color: '#8E8E93', + fontSize: 16, + marginRight: 8, + width: 20, + }, + optionInput: { + flex: 1, + backgroundColor: '#2C2C2E', + borderRadius: 10, + padding: 12, + fontSize: 16, + color: '#FFFFFF', + }, + removeBtn: { + marginLeft: 8, + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: '#3A3A3C', + alignItems: 'center', + justifyContent: 'center', + }, + removeBtnText: { + color: '#FF453A', + fontSize: 14, + fontWeight: '700', + }, + addOptionBtn: { + paddingVertical: 12, + alignItems: 'center', + borderWidth: 1, + borderColor: '#3A3A3C', + borderRadius: 10, + borderStyle: 'dashed', + marginTop: 4, + }, + addOptionText: { + color: '#0A84FF', + fontSize: 15, + fontWeight: '600', + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 20, + paddingVertical: 8, + }, + switchLabel: { + fontSize: 16, + color: '#FFFFFF', + }, + buttons: { + flexDirection: 'row', + marginTop: 24, + gap: 12, + }, + cancelBtn: { + flex: 1, + paddingVertical: 14, + borderRadius: 10, + backgroundColor: '#3A3A3C', + alignItems: 'center', + }, + cancelBtnText: { + fontSize: 16, + fontWeight: '600', + color: '#FFFFFF', + }, + createBtn: { + flex: 2, + paddingVertical: 14, + borderRadius: 10, + backgroundColor: '#0A84FF', + alignItems: 'center', + }, + createBtnDisabled: { + opacity: 0.4, + }, + createBtnText: { + fontSize: 16, + fontWeight: '700', + color: '#FFFFFF', + }, +}); diff --git a/bounty-3/components/PollResults.tsx b/bounty-3/components/PollResults.tsx new file mode 100644 index 00000000..42a4409e --- /dev/null +++ b/bounty-3/components/PollResults.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native'; +import { Poll, PollResult, POLL_COLORS } from '../types'; + +interface PollResultsProps { + poll: Poll; + onViewVotes: (pollId: string) => void; +} + +function getResults(poll: Poll): PollResult[] { + const total = poll.options.reduce((sum, o) => sum + o.voters.length, 0); + return poll.options.map((option, i) => ({ + optionId: option.id, + text: option.text, + voteCount: option.voters.length, + percentage: total > 0 ? (option.voters.length / total) * 100 : 0, + color: POLL_COLORS[i % POLL_COLORS.length], + voters: [...option.voters], + })); +} + +function getLeadingOptionIds(results: PollResult[]): string[] { + const maxVotes = Math.max(...results.map((r) => r.voteCount)); + if (maxVotes === 0) return []; + return results.filter((r) => r.voteCount === maxVotes).map((r) => r.optionId); +} + +export const PollResults: React.FC = ({ poll, onViewVotes }) => { + const results = getResults(poll); + const totalVotes = results.reduce((s, r) => s + r.voteCount, 0); + const leadingIds = getLeadingOptionIds(results); + + return ( + + {results.map((result) => { + const isLeading = leadingIds.includes(result.optionId); + const isUserVote = poll.userVote?.includes(result.optionId); + + return ( + + + + {isLeading && totalVotes > 0 && ( + 👑 + )} + + {result.text} + + + + {result.voteCount} vote{result.voteCount !== 1 ? 's' : ''} ({Math.round(result.percentage)}%) + + + + + {isUserVote && ( + + )} + + + ); + })} + + + {totalVotes} total vote{totalVotes !== 1 ? 's' : ''} + onViewVotes(poll.id)} + > + View Votes + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + gap: 12, + }, + resultRow: { + gap: 4, + }, + labelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + labelLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + marginRight: 8, + }, + crown: { + fontSize: 12, + marginRight: 4, + }, + optionLabel: { + fontSize: 15, + color: '#FFFFFF', + flexShrink: 1, + }, + optionLabelVoted: { + fontWeight: '700', + color: '#0A84FF', + }, + statText: { + fontSize: 13, + color: '#8E8E93', + }, + barBg: { + height: 28, + backgroundColor: '#2C2C2E', + borderRadius: 6, + overflow: 'hidden', + position: 'relative', + justifyContent: 'center', + }, + bar: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + borderRadius: 6, + opacity: 0.7, + }, + voteDot: { + position: 'absolute', + right: 8, + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#0A84FF', + }, + footer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 4, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: '#2C2C2E', + }, + totalText: { + fontSize: 13, + color: '#8E8E93', + }, + viewVotesBtn: { + paddingHorizontal: 14, + paddingVertical: 6, + borderRadius: 8, + backgroundColor: '#2C2C2E', + }, + viewVotesText: { + fontSize: 13, + color: '#0A84FF', + fontWeight: '600', + }, +}); diff --git a/bounty-3/components/PollVoting.tsx b/bounty-3/components/PollVoting.tsx new file mode 100644 index 00000000..45ed9ab8 --- /dev/null +++ b/bounty-3/components/PollVoting.tsx @@ -0,0 +1,167 @@ +import React, { useState, useCallback } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { Poll } from '../types'; + +interface PollVotingProps { + poll: Poll; + currentUserId: string; + currentUsername: string; + onVote: (pollId: string, optionIds: string[]) => void; +} + +export const PollVoting: React.FC = ({ + poll, + currentUserId, + currentUsername, + onVote, +}) => { + const [selectedIds, setSelectedIds] = useState([]); + + const toggleOption = useCallback( + (optionId: string) => { + if (poll.isMultiChoice) { + setSelectedIds((prev) => + prev.includes(optionId) + ? prev.filter((id) => id !== optionId) + : [...prev, optionId] + ); + } else { + setSelectedIds((prev) => + prev.includes(optionId) ? [] : [optionId] + ); + } + }, + [poll.isMultiChoice] + ); + + const handleSubmit = useCallback(() => { + if (selectedIds.length === 0) return; + onVote(poll.id, selectedIds); + }, [poll.id, selectedIds, onVote]); + + return ( + + {poll.options.map((option) => { + const isSelected = selectedIds.includes(option.id); + return ( + toggleOption(option.id)} + activeOpacity={0.7} + > + + {poll.isMultiChoice ? ( + + {isSelected && } + + ) : ( + + {isSelected && } + + )} + + + {option.text} + + + ); + })} + + + Vote + + + ); +}; + +const styles = StyleSheet.create({ + container: { + gap: 8, + }, + option: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#2C2C2E', + borderRadius: 10, + padding: 14, + borderWidth: 1, + borderColor: 'transparent', + }, + optionSelected: { + borderColor: '#0A84FF', + backgroundColor: '#0A84FF20', + }, + radioOuter: { + marginRight: 12, + justifyContent: 'center', + alignItems: 'center', + }, + radio: { + width: 22, + height: 22, + borderRadius: 11, + borderWidth: 2, + borderColor: '#8E8E93', + justifyContent: 'center', + alignItems: 'center', + }, + radioSelected: { + borderColor: '#0A84FF', + }, + radioDot: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: '#0A84FF', + }, + checkbox: { + width: 22, + height: 22, + borderRadius: 6, + borderWidth: 2, + borderColor: '#8E8E93', + justifyContent: 'center', + alignItems: 'center', + }, + checkboxChecked: { + borderColor: '#0A84FF', + backgroundColor: '#0A84FF', + }, + checkMark: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '700', + }, + optionText: { + flex: 1, + fontSize: 15, + color: '#FFFFFF', + }, + optionTextSelected: { + color: '#0A84FF', + fontWeight: '600', + }, + submitBtn: { + marginTop: 8, + backgroundColor: '#0A84FF', + borderRadius: 10, + paddingVertical: 14, + alignItems: 'center', + }, + submitBtnDisabled: { + opacity: 0.4, + }, + submitBtnText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '700', + }, +}); diff --git a/bounty-3/components/ViewVotesSheet.tsx b/bounty-3/components/ViewVotesSheet.tsx new file mode 100644 index 00000000..46665fd5 --- /dev/null +++ b/bounty-3/components/ViewVotesSheet.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + ScrollView, + SectionList, +} from 'react-native'; +import { Poll, PollResult, POLL_COLORS } from '../types'; + +interface ViewVotesSheetProps { + visible: boolean; + poll: Poll | null; + onClose: () => void; +} + +function getResults(poll: Poll): PollResult[] { + const total = poll.options.reduce((sum, o) => sum + o.voters.length, 0); + return poll.options.map((option, i) => ({ + optionId: option.id, + text: option.text, + voteCount: option.voters.length, + percentage: total > 0 ? (option.voters.length / total) * 100 : 0, + color: POLL_COLORS[i % POLL_COLORS.length], + voters: [...option.voters], + })); +} + +export const ViewVotesSheet: React.FC = ({ + visible, + poll, + onClose, +}) => { + if (!visible || !poll) return null; + + const results = getResults(poll); + const sections = results + .filter((r) => r.voters.length > 0) + .map((r) => ({ + title: `${r.text} (${r.voteCount})`, + data: r.voters, + color: r.color, + })); + + const noVotes = sections.length === 0; + + return ( + + + + + + Done + + + + Votes + {poll.question} + + {noVotes ? ( + + No votes yet + + ) : ( + `${item.userId}_${index}`} + style={styles.list} + renderSectionHeader={({ section }) => ( + + + + {section.title} + + + )} + renderItem={({ item }) => ( + + + + {item.username.charAt(0).toUpperCase()} + + + + {item.username} + + {new Date(item.votedAt).toLocaleString()} + + + + )} + showsVerticalScrollIndicator={false} + /> + )} + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + zIndex: 1001, + }, + sheet: { + backgroundColor: '#1C1C1E', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: '80%', + paddingBottom: 34, + }, + handleRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + paddingTop: 8, + paddingBottom: 4, + paddingHorizontal: 20, + }, + handle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#3A3A3C', + }, + closeBtn: { + position: 'absolute', + right: 20, + top: 8, + }, + closeBtnText: { + fontSize: 16, + color: '#0A84FF', + fontWeight: '600', + }, + title: { + fontSize: 20, + fontWeight: '700', + color: '#FFFFFF', + textAlign: 'center', + marginTop: 8, + }, + questionText: { + fontSize: 14, + color: '#8E8E93', + textAlign: 'center', + marginTop: 4, + marginBottom: 16, + paddingHorizontal: 20, + }, + list: { + paddingHorizontal: 20, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: '#2C2C2E', + }, + colorDot: { + width: 10, + height: 10, + borderRadius: 5, + marginRight: 8, + }, + sectionTitle: { + fontSize: 15, + fontWeight: '700', + color: '#FFFFFF', + flex: 1, + }, + voterRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingLeft: 18, + }, + avatar: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#0A84FF', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + avatarText: { + fontSize: 16, + fontWeight: '700', + color: '#FFFFFF', + }, + voterInfo: { + flex: 1, + }, + voterName: { + fontSize: 15, + color: '#FFFFFF', + fontWeight: '500', + }, + voterTime: { + fontSize: 12, + color: '#8E8E93', + marginTop: 2, + }, + emptyState: { + paddingVertical: 60, + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: '#8E8E93', + }, +}); diff --git a/bounty-3/state/pollStore.ts b/bounty-3/state/pollStore.ts new file mode 100644 index 00000000..394ace98 --- /dev/null +++ b/bounty-3/state/pollStore.ts @@ -0,0 +1,107 @@ +import { makeAutoObservable } from 'mobx'; +import { + Poll, + PollOption, + Vote, + PollResult, + PollCreationData, + POLL_COLORS, +} from '../types'; + +let pollCounter = 0; + +function generateId(): string { + pollCounter += 1; + return `poll_${Date.now()}_${pollCounter}`; +} + +function optionId(pollId: string, index: number): string { + return `${pollId}_opt_${index}`; +} + +export class PollStore { + polls: Map = new Map(); + + constructor() { + makeAutoObservable(this); + } + + createPoll(data: PollCreationData, createdBy: string): Poll { + const id = generateId(); + const options: PollOption[] = data.options.map((text, i) => ({ + id: optionId(id, i), + text, + voters: [], + })); + + const poll: Poll = { + id, + question: data.question, + options, + isMultiChoice: data.isMultiChoice, + createdBy, + createdAt: Date.now(), + userVote: undefined, + }; + + this.polls.set(id, poll); + return poll; + } + + getPoll(pollId: string): Poll | undefined { + return this.polls.get(pollId); + } + + castVote(pollId: string, optionIds: string[], userId: string, username: string): boolean { + const poll = this.polls.get(pollId); + if (!poll) return false; + + if (poll.userVote) return false; + + if (!poll.isMultiChoice && optionIds.length > 1) return false; + + const vote: Vote = { + userId, + username, + votedAt: Date.now(), + }; + + for (const optionId of optionIds) { + const option = poll.options.find((o) => o.id === optionId); + if (!option) return false; + option.voters.push(vote); + } + + poll.userVote = optionIds; + return true; + } + + getResults(pollId: string): PollResult[] { + const poll = this.polls.get(pollId); + if (!poll) return []; + + const totalVotes = poll.options.reduce((sum, o) => sum + o.voters.length, 0); + + return poll.options.map((option, i) => ({ + optionId: option.id, + text: option.text, + voteCount: option.voters.length, + percentage: totalVotes > 0 ? (option.voters.length / totalVotes) * 100 : 0, + color: POLL_COLORS[i % POLL_COLORS.length], + voters: [...option.voters], + })); + } + + hasUserVoted(pollId: string): boolean { + const poll = this.polls.get(pollId); + return poll?.userVote !== undefined; + } + + getTotalVotes(pollId: string): number { + const poll = this.polls.get(pollId); + if (!poll) return 0; + return poll.options.reduce((sum, o) => sum + o.voters.length, 0); + } +} + +export const pollStore = new PollStore(); diff --git a/bounty-3/types.ts b/bounty-3/types.ts new file mode 100644 index 00000000..219e9c9d --- /dev/null +++ b/bounty-3/types.ts @@ -0,0 +1,57 @@ +export interface PollOption { + id: string; + text: string; + voters: Vote[]; +} + +export interface Poll { + id: string; + question: string; + options: PollOption[]; + isMultiChoice: boolean; + createdBy: string; + createdAt: number; + userVote?: string[]; +} + +export interface Vote { + userId: string; + username: string; + avatarUrl?: string; + votedAt: number; +} + +export interface PollResult { + optionId: string; + text: string; + voteCount: number; + percentage: number; + color: string; + voters: Vote[]; +} + +export interface PollCreationData { + question: string; + options: string[]; + isMultiChoice: boolean; +} + +export const MAX_OPTIONS = 12; +export const MIN_OPTIONS = 2; +export const OPTION_MAX_LENGTH = 255; +export const QUESTION_MAX_LENGTH = 500; + +export const POLL_COLORS = [ + '#4A90D9', + '#50C878', + '#F5A623', + '#E74C3C', + '#9B59B6', + '#1ABC9C', + '#E67E22', + '#3498DB', + '#2ECC71', + '#F39C12', + '#8E44AD', + '#16A085', +];