-
Notifications
You must be signed in to change notification settings - Fork 7
Add Sponsor system: DB migration, service layer, and app-wide badge UI #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import React from 'react'; | ||
|
|
||
| type SponsorBadgeProps = { | ||
| size?: 'sm' | 'md' | 'lg'; | ||
| showLabel?: boolean; | ||
| animate?: boolean; | ||
| count?: number; | ||
| }; | ||
|
|
||
| const sizeClasses = { | ||
| sm: 'text-xs px-2 py-1', | ||
| md: 'text-sm px-2.5 py-1.5', | ||
| lg: 'text-base px-3 py-2', | ||
| }; | ||
|
|
||
| const SponsorBadge: React.FC<SponsorBadgeProps> = ({ | ||
| size = 'md', | ||
| showLabel = false, | ||
| animate = true, | ||
| count = 0, | ||
| }) => { | ||
| return ( | ||
| <span | ||
| title={`Sponsored ${count} spots`} | ||
| className={`inline-flex items-center gap-1.5 rounded-full border border-yellow-400/40 bg-gradient-to-r from-yellow-500/20 via-amber-300/20 to-yellow-500/20 text-yellow-200 shadow-[0_0_20px_rgba(250,204,21,0.35)] ${sizeClasses[size]} ${animate ? 'animate-pulse' : ''}`} | ||
| > | ||
| <span className="drop-shadow-[0_0_10px_rgba(250,204,21,0.8)]">⭐</span> | ||
| {showLabel && <span className="font-bold">Sponsor</span>} | ||
| {count > 0 && <span className="font-semibold">{count}</span>} | ||
| </span> | ||
| ); | ||
| }; | ||
|
|
||
| export default SponsorBadge; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -10,21 +10,23 @@ import { | |||||||||||||
| InvitationStatus, | ||||||||||||||
| PaymentStatus, | ||||||||||||||
| UserRole, | ||||||||||||||
| SpotSponsor, | ||||||||||||||
| } from "../types"; | ||||||||||||||
| import Card from "../components/common/Card"; | ||||||||||||||
| import Button from "../components/common/Button"; | ||||||||||||||
| import Modal from "../components/common/Modal"; | ||||||||||||||
| import Input from "../components/common/Input"; | ||||||||||||||
| import GlowButton from "../components/common/GlowButton"; | ||||||||||||||
| import Textarea from "../components/common/Textarea"; | ||||||||||||||
| import { spotService, invitationService, paymentService, notificationService, profileService } from "../services/database"; | ||||||||||||||
| import { spotService, invitationService, paymentService, notificationService, profileService, sponsorService } from "../services/database"; | ||||||||||||||
| import { supabase } from "../services/supabase"; | ||||||||||||||
| import { checkDatabaseSetup, getSetupInstructions } from "../services/dbCheck"; | ||||||||||||||
| import { useNotifications } from "../contexts/NotificationsContext"; | ||||||||||||||
| import { format } from "date-fns"; | ||||||||||||||
| import ShinyText from "../components/common/ShinyText"; | ||||||||||||||
| import GradientText from "../components/common/GradientText"; | ||||||||||||||
| import StarBorder from "../components/common/StarBorder"; | ||||||||||||||
| import SponsorBadge from "../components/common/SponsorBadge"; | ||||||||||||||
|
|
||||||||||||||
| declare const google: any; | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -47,6 +49,10 @@ const HomePage: React.FC = () => { | |||||||||||||
| feedback: '', | ||||||||||||||
| }); | ||||||||||||||
| const [dbSetupError, setDbSetupError] = useState<string | null>(null); | ||||||||||||||
| const [spotSponsor, setSpotSponsor] = useState<SpotSponsor | null>(null); | ||||||||||||||
| const [allProfiles, setAllProfiles] = useState<any[]>([]); | ||||||||||||||
| const [isSponsorModalOpen, setIsSponsorModalOpen] = useState(false); | ||||||||||||||
| const [sponsorForm, setSponsorForm] = useState({ sponsor_id: '', amount_covered: '', message: '' }); | ||||||||||||||
|
|
||||||||||||||
| const [newSpotData, setNewSpotData] = useState({ | ||||||||||||||
| location: "", | ||||||||||||||
|
|
@@ -85,11 +91,19 @@ const HomePage: React.FC = () => { | |||||||||||||
| setSpot(spotData); | ||||||||||||||
|
|
||||||||||||||
| if (spotData) { | ||||||||||||||
| const inv = await invitationService.getInvitations(spotData.id); | ||||||||||||||
| const [inv, sponsor] = await Promise.all([ | ||||||||||||||
| invitationService.getInvitations(spotData.id), | ||||||||||||||
| sponsorService.getSponsor(spotData.id), | ||||||||||||||
| ]); | ||||||||||||||
| setInvitations(inv); | ||||||||||||||
| setSpotSponsor(sponsor); | ||||||||||||||
| } else { | ||||||||||||||
| setInvitations([]); | ||||||||||||||
| setSpotSponsor(null); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const profiles = await profileService.getAllProfiles(); | ||||||||||||||
| setAllProfiles(profiles || []); | ||||||||||||||
|
Comment on lines
+105
to
+106
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 getAllProfiles called on every fetchData for all users, not just admins In Root Cause and ImpactAt Impact: Every non-admin user makes an unnecessary database query fetching all profiles on every page load and every real-time refresh. This is a performance issue and potentially a data exposure concern (all profile data fetched client-side for non-admin users).
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||
| } catch (err: any) { | ||||||||||||||
| console.error("Error fetching data:", err); | ||||||||||||||
| if (err.message?.includes('does not exist') || err.message?.includes('relation')) { | ||||||||||||||
|
|
@@ -109,6 +123,7 @@ const HomePage: React.FC = () => { | |||||||||||||
| // Set up real-time subscriptions | ||||||||||||||
| let spotChannel: any = null; | ||||||||||||||
| let invitationChannel: any = null; | ||||||||||||||
| let sponsorChannel: any = null; | ||||||||||||||
|
|
||||||||||||||
| if (spot) { | ||||||||||||||
| // Subscribe to spot changes | ||||||||||||||
|
|
@@ -128,6 +143,8 @@ const HomePage: React.FC = () => { | |||||||||||||
| } | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| sponsorChannel = sponsorService.subscribeToSponsors(() => fetchData()); | ||||||||||||||
|
|
||||||||||||||
| // Subscribe to invitation changes for this spot | ||||||||||||||
| invitationChannel = invitationService.subscribeToInvitations( | ||||||||||||||
| spot.id, | ||||||||||||||
|
|
@@ -160,6 +177,9 @@ const HomePage: React.FC = () => { | |||||||||||||
| if (invitationChannel) { | ||||||||||||||
| supabase.removeChannel(invitationChannel); | ||||||||||||||
| } | ||||||||||||||
| if (sponsorChannel) { | ||||||||||||||
| supabase.removeChannel(sponsorChannel); | ||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
| }, [fetchData, spot?.id, notify]); | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -631,6 +651,36 @@ const HomePage: React.FC = () => { | |||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| const handleAssignSponsor = async (e: React.FormEvent) => { | ||||||||||||||
| e.preventDefault(); | ||||||||||||||
| if (!spot || !sponsorForm.sponsor_id || !sponsorForm.amount_covered) return; | ||||||||||||||
|
|
||||||||||||||
| try { | ||||||||||||||
| await sponsorService.sponsorSpot({ | ||||||||||||||
| spot_id: spot.id, | ||||||||||||||
| sponsor_id: sponsorForm.sponsor_id, | ||||||||||||||
| amount_covered: Number(sponsorForm.amount_covered), | ||||||||||||||
| message: sponsorForm.message, | ||||||||||||||
| }); | ||||||||||||||
| setIsSponsorModalOpen(false); | ||||||||||||||
| setSponsorForm({ sponsor_id: '', amount_covered: '', message: '' }); | ||||||||||||||
| await fetchData(); | ||||||||||||||
| } catch (error: any) { | ||||||||||||||
| alert(`Failed to assign sponsor: ${error.message || 'Please try again.'}`); | ||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| const handleRemoveSponsor = async () => { | ||||||||||||||
| if (!spot) return; | ||||||||||||||
| try { | ||||||||||||||
| await sponsorService.removeSponsor(spot.id); | ||||||||||||||
| await fetchData(); | ||||||||||||||
| } catch (error: any) { | ||||||||||||||
| alert(`Failed to remove sponsor: ${error.message || 'Please try again.'}`); | ||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| /* ----------------------------- UI ----------------------------- */ | ||||||||||||||
|
|
||||||||||||||
| return ( | ||||||||||||||
|
|
@@ -696,9 +746,23 @@ const HomePage: React.FC = () => { | |||||||||||||
| day: 'numeric' | ||||||||||||||
| })} at {spot.timing} | ||||||||||||||
| </p> | ||||||||||||||
| {spotSponsor && ( | ||||||||||||||
| <div className="mt-3 inline-flex items-center gap-2 px-3 py-2 rounded-xl bg-yellow-500/10 border border-yellow-500/30"> | ||||||||||||||
| <span>🎉 This spot is sponsored by <strong>{spotSponsor.sponsor?.name || 'A Bro'}</strong>!</span> | ||||||||||||||
| <SponsorBadge size="sm" showLabel={false} count={spotSponsor.sponsor?.sponsor_count || 0} /> | ||||||||||||||
| </div> | ||||||||||||||
| )} | ||||||||||||||
| </div> | ||||||||||||||
| {isAdmin && ( | ||||||||||||||
| <div className="flex gap-2"> | ||||||||||||||
| <Button size="sm" variant="secondary" onClick={() => setIsSponsorModalOpen(true)}> | ||||||||||||||
| Add Sponsor | ||||||||||||||
| </Button> | ||||||||||||||
| {spotSponsor && ( | ||||||||||||||
| <Button size="sm" variant="secondary" onClick={handleRemoveSponsor}> | ||||||||||||||
| Remove Sponsor | ||||||||||||||
| </Button> | ||||||||||||||
| )} | ||||||||||||||
| <Button | ||||||||||||||
| size="sm" | ||||||||||||||
| variant="secondary" | ||||||||||||||
|
|
@@ -905,6 +969,42 @@ const HomePage: React.FC = () => { | |||||||||||||
| </> | ||||||||||||||
| )} | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| <Modal | ||||||||||||||
| isOpen={isSponsorModalOpen} | ||||||||||||||
| onClose={() => setIsSponsorModalOpen(false)} | ||||||||||||||
| title="Add Sponsor" | ||||||||||||||
| > | ||||||||||||||
| <form onSubmit={handleAssignSponsor} className="space-y-4"> | ||||||||||||||
| <div> | ||||||||||||||
| <label className="text-sm text-zinc-400">Sponsor</label> | ||||||||||||||
| <select | ||||||||||||||
| value={sponsorForm.sponsor_id} | ||||||||||||||
| onChange={(e) => setSponsorForm(prev => ({ ...prev, sponsor_id: e.target.value }))} | ||||||||||||||
| className="w-full mt-2 bg-zinc-900 border border-white/10 rounded-lg p-3" | ||||||||||||||
| required | ||||||||||||||
| > | ||||||||||||||
| <option value="">Select member</option> | ||||||||||||||
| {allProfiles.map((member) => ( | ||||||||||||||
| <option key={member.id} value={member.id}>{member.name} (@{member.username})</option> | ||||||||||||||
| ))} | ||||||||||||||
| </select> | ||||||||||||||
| </div> | ||||||||||||||
| <Input | ||||||||||||||
| label="Amount Covered" | ||||||||||||||
| type="number" | ||||||||||||||
| value={sponsorForm.amount_covered} | ||||||||||||||
| onChange={(e) => setSponsorForm(prev => ({ ...prev, amount_covered: e.target.value }))} | ||||||||||||||
| /> | ||||||||||||||
| <Textarea | ||||||||||||||
| label="Message" | ||||||||||||||
| value={sponsorForm.message} | ||||||||||||||
| onChange={(e) => setSponsorForm(prev => ({ ...prev, message: e.target.value }))} | ||||||||||||||
| /> | ||||||||||||||
| <Button type="submit" className="w-full">Assign Sponsor</Button> | ||||||||||||||
| </form> | ||||||||||||||
| </Modal> | ||||||||||||||
|
|
||||||||||||||
| {/* CREATE SPOT MODAL */} | ||||||||||||||
| <Modal | ||||||||||||||
| isOpen={isCreateSpotModalOpen} | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor: Pluralization issue in title attribute.
When
countis 1, the title reads "Sponsored 1 spots" instead of "Sponsored 1 spot".🛠️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents