diff --git a/DATABASE_SCHEMA.md b/DATABASE_SCHEMA.md index 94a872a..d1daa37 100644 --- a/DATABASE_SCHEMA.md +++ b/DATABASE_SCHEMA.md @@ -336,3 +336,28 @@ VALUES ( 3. The `username` field has a UNIQUE constraint to ensure uniqueness 4. All timestamps use `TIMESTAMP WITH TIME ZONE` for proper timezone handling 5. Foreign key constraints ensure data integrity + +### 8. `spot_sponsors` table + +Stores a single sponsor assignment per spot. + +```sql +CREATE TABLE spot_sponsors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + spot_id UUID NOT NULL REFERENCES spots(id) ON DELETE CASCADE UNIQUE, + sponsor_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + amount_covered NUMERIC NOT NULL DEFAULT 0, + message TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +ALTER TABLE profiles + ADD COLUMN is_sponsor BOOLEAN DEFAULT false, + ADD COLUMN sponsor_count INTEGER DEFAULT 0; + +ALTER TABLE spots + ADD COLUMN is_sponsored BOOLEAN DEFAULT false, + ADD COLUMN sponsored_by UUID REFERENCES profiles(id) ON DELETE SET NULL; +``` + +A trigger on `spot_sponsors` should permanently mark the sponsor profile and increment `sponsor_count` whenever a new sponsorship is inserted. diff --git a/components/common/SponsorBadge.tsx b/components/common/SponsorBadge.tsx new file mode 100644 index 0000000..15311b1 --- /dev/null +++ b/components/common/SponsorBadge.tsx @@ -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 = ({ + size = 'md', + showLabel = false, + animate = true, + count = 0, +}) => { + return ( + + + {showLabel && Sponsor} + {count > 0 && {count}} + + ); +}; + +export default SponsorBadge; diff --git a/contexts/ChatContext.tsx b/contexts/ChatContext.tsx index 86a1b55..496601f 100644 --- a/contexts/ChatContext.tsx +++ b/contexts/ChatContext.tsx @@ -75,7 +75,9 @@ export function ChatProvider({ children }: { children: ReactNode }) { *, profiles:user_id ( name, - profile_pic_url + profile_pic_url, + is_sponsor, + sponsor_count ) `) .order('created_at', { ascending: true }); @@ -92,6 +94,8 @@ export function ChatProvider({ children }: { children: ReactNode }) { profiles: { name: msg.profiles?.name || 'Unknown', profile_pic_url: msg.profiles?.profile_pic_url || 'https://api.dicebear.com/7.x/thumbs/svg?seed=default', + is_sponsor: msg.profiles?.is_sponsor || false, + sponsor_count: msg.profiles?.sponsor_count || 0, }, })); @@ -123,7 +127,9 @@ export function ChatProvider({ children }: { children: ReactNode }) { *, profiles:user_id ( name, - profile_pic_url + profile_pic_url, + is_sponsor, + sponsor_count ) `) .eq('id', payload.new.id) @@ -140,6 +146,8 @@ export function ChatProvider({ children }: { children: ReactNode }) { profiles: { name: newMsg.profiles?.name || 'Unknown', profile_pic_url: newMsg.profiles?.profile_pic_url || 'https://api.dicebear.com/7.x/thumbs/svg?seed=default', + is_sponsor: newMsg.profiles?.is_sponsor || false, + sponsor_count: newMsg.profiles?.sponsor_count || 0, }, }; diff --git a/pages/ChatPage.tsx b/pages/ChatPage.tsx index 047cef5..47b8aee 100644 --- a/pages/ChatPage.tsx +++ b/pages/ChatPage.tsx @@ -7,6 +7,7 @@ import { format } from 'date-fns'; import { motion, AnimatePresence } from 'framer-motion'; import * as ReactRouterDOM from 'react-router-dom'; import { useChat } from '../contexts/ChatContext'; +import SponsorBadge from '../components/common/SponsorBadge'; const PhotoGallery: React.FC<{ urls: string[] }> = ({ urls }) => { if (!urls || urls.length === 0) return null; @@ -199,6 +200,7 @@ const ChatPage: React.FC = () => { onClick={() => window.location.href = `/dashboard/profile/${msg.user_id}`} > {msg.profiles.name} + {msg.profiles.is_sponsor && } )} diff --git a/pages/DrinksPage.tsx b/pages/DrinksPage.tsx index f339073..47d1f78 100644 --- a/pages/DrinksPage.tsx +++ b/pages/DrinksPage.tsx @@ -5,10 +5,11 @@ 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 { spotService, paymentService, drinkService, cigaretteService, foodService, drinkBrandService, userDrinkSelectionService } from "../services/database"; +import { spotService, paymentService, drinkService, cigaretteService, foodService, drinkBrandService, userDrinkSelectionService, sponsorService } from "../services/database"; import { supabase } from "../services/supabase"; import { Plus, ThumbsUp, Trash2, Loader2, Image as ImageIcon, X, Camera, ShoppingCart, Minus, Check, Wine, Search, Menu, ArrowLeft, Star, Edit, Utensils, Download } from "lucide-react"; import ShinyText from "../components/common/ShinyText"; +import SponsorBadge from "../components/common/SponsorBadge"; const DrinksPage: React.FC = () => { const { profile } = useAuth(); @@ -21,6 +22,7 @@ const DrinksPage: React.FC = () => { const [loading, setLoading] = useState(true); const [isPaid, setIsPaid] = useState(false); const [pageError, setPageError] = useState(null); + const [spotSponsor, setSpotSponsor] = useState(null); // UI State const [activeSection, setActiveSection] = useState<'browse' | 'checkout' | 'detail'>('browse'); @@ -130,6 +132,8 @@ const DrinksPage: React.FC = () => { setSpot(spotData); if (spotData) { + const sponsor = await sponsorService.getSponsor(spotData.id); + setSpotSponsor(sponsor); let userId: string; try { userId = await getUserIdAsUUID(profile.id); @@ -191,6 +195,7 @@ const DrinksPage: React.FC = () => { } else { setDrinks([]); setIsPaid(false); + setSpotSponsor(null); } } catch (error: any) { console.error("Error loading drinks data:", error); @@ -620,7 +625,7 @@ const DrinksPage: React.FC = () => { } }; - const totalCartAmount = userSelections.reduce((sum, sel) => sum + sel.total_price, 0); + const totalCartAmount = spotSponsor ? 0 : userSelections.reduce((sum, sel) => sum + sel.total_price, 0); const cartItemCount = userSelections.reduce((sum, sel) => sum + sel.quantity, 0); const categories = ['all', 'wine', 'beer', 'spirits']; @@ -741,6 +746,14 @@ const DrinksPage: React.FC = () => { URL.revokeObjectURL(url); }; + + const sponsorBanner = spotSponsor ? ( +
+

🎉 This spot is sponsored by {spotSponsor.sponsor?.name || 'A Bro'}! Everyone pays Rs.0

+ +
+ ) : null; + // Safety check: don't render if profile is not available yet if (!profile) { return ( @@ -803,6 +816,7 @@ const DrinksPage: React.FC = () => { return (

Bar Menu

+ {sponsorBanner}

You need to complete payment first to access the bar.

@@ -1705,6 +1719,7 @@ const DrinksPage: React.FC = () => {
+ {sponsorBanner}

Checkout

{/* Order List */} diff --git a/pages/HomePage.tsx b/pages/HomePage.tsx index 20a9039..d6348ac 100644 --- a/pages/HomePage.tsx +++ b/pages/HomePage.tsx @@ -10,6 +10,7 @@ import { InvitationStatus, PaymentStatus, UserRole, + SpotSponsor, } from "../types"; import Card from "../components/common/Card"; import Button from "../components/common/Button"; @@ -17,7 +18,7 @@ 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"; @@ -25,6 +26,7 @@ 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(null); + const [spotSponsor, setSpotSponsor] = useState(null); + const [allProfiles, setAllProfiles] = useState([]); + 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 || []); } 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}

+ {spotSponsor && ( +
+ 🎉 This spot is sponsored by {spotSponsor.sponsor?.name || 'A Bro'}! + +
+ )}
{isAdmin && (
+ + {spotSponsor && ( + + )}