Skip to content

Commit f750d0f

Browse files
committed
Add sponsor system backend, service layer, and badge UI integration
1 parent de2ba8d commit f750d0f

10 files changed

Lines changed: 405 additions & 10 deletions

File tree

DATABASE_SCHEMA.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,3 +336,28 @@ VALUES (
336336
3. The `username` field has a UNIQUE constraint to ensure uniqueness
337337
4. All timestamps use `TIMESTAMP WITH TIME ZONE` for proper timezone handling
338338
5. Foreign key constraints ensure data integrity
339+
340+
### 8. `spot_sponsors` table
341+
342+
Stores a single sponsor assignment per spot.
343+
344+
```sql
345+
CREATE TABLE spot_sponsors (
346+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
347+
spot_id UUID NOT NULL REFERENCES spots(id) ON DELETE CASCADE UNIQUE,
348+
sponsor_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
349+
amount_covered NUMERIC NOT NULL DEFAULT 0,
350+
message TEXT,
351+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
352+
);
353+
354+
ALTER TABLE profiles
355+
ADD COLUMN is_sponsor BOOLEAN DEFAULT false,
356+
ADD COLUMN sponsor_count INTEGER DEFAULT 0;
357+
358+
ALTER TABLE spots
359+
ADD COLUMN is_sponsored BOOLEAN DEFAULT false,
360+
ADD COLUMN sponsored_by UUID REFERENCES profiles(id) ON DELETE SET NULL;
361+
```
362+
363+
A trigger on `spot_sponsors` should permanently mark the sponsor profile and increment `sponsor_count` whenever a new sponsorship is inserted.

components/common/SponsorBadge.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React from 'react';
2+
3+
type SponsorBadgeProps = {
4+
size?: 'sm' | 'md' | 'lg';
5+
showLabel?: boolean;
6+
animate?: boolean;
7+
count?: number;
8+
};
9+
10+
const sizeClasses = {
11+
sm: 'text-xs px-2 py-1',
12+
md: 'text-sm px-2.5 py-1.5',
13+
lg: 'text-base px-3 py-2',
14+
};
15+
16+
const SponsorBadge: React.FC<SponsorBadgeProps> = ({
17+
size = 'md',
18+
showLabel = false,
19+
animate = true,
20+
count = 0,
21+
}) => {
22+
return (
23+
<span
24+
title={`Sponsored ${count} spots`}
25+
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' : ''}`}
26+
>
27+
<span className="drop-shadow-[0_0_10px_rgba(250,204,21,0.8)]"></span>
28+
{showLabel && <span className="font-bold">Sponsor</span>}
29+
{count > 0 && <span className="font-semibold">{count}</span>}
30+
</span>
31+
);
32+
};
33+
34+
export default SponsorBadge;

contexts/ChatContext.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
7575
*,
7676
profiles:user_id (
7777
name,
78-
profile_pic_url
78+
profile_pic_url,
79+
is_sponsor,
80+
sponsor_count
7981
)
8082
`)
8183
.order('created_at', { ascending: true });
@@ -92,6 +94,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
9294
profiles: {
9395
name: msg.profiles?.name || 'Unknown',
9496
profile_pic_url: msg.profiles?.profile_pic_url || 'https://api.dicebear.com/7.x/thumbs/svg?seed=default',
97+
is_sponsor: msg.profiles?.is_sponsor || false,
98+
sponsor_count: msg.profiles?.sponsor_count || 0,
9599
},
96100
}));
97101

@@ -123,7 +127,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
123127
*,
124128
profiles:user_id (
125129
name,
126-
profile_pic_url
130+
profile_pic_url,
131+
is_sponsor,
132+
sponsor_count
127133
)
128134
`)
129135
.eq('id', payload.new.id)
@@ -140,6 +146,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
140146
profiles: {
141147
name: newMsg.profiles?.name || 'Unknown',
142148
profile_pic_url: newMsg.profiles?.profile_pic_url || 'https://api.dicebear.com/7.x/thumbs/svg?seed=default',
149+
is_sponsor: newMsg.profiles?.is_sponsor || false,
150+
sponsor_count: newMsg.profiles?.sponsor_count || 0,
143151
},
144152
};
145153

pages/ChatPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { format } from 'date-fns';
77
import { motion, AnimatePresence } from 'framer-motion';
88
import * as ReactRouterDOM from 'react-router-dom';
99
import { useChat } from '../contexts/ChatContext';
10+
import SponsorBadge from '../components/common/SponsorBadge';
1011

1112
const PhotoGallery: React.FC<{ urls: string[] }> = ({ urls }) => {
1213
if (!urls || urls.length === 0) return null;
@@ -199,6 +200,7 @@ const ChatPage: React.FC = () => {
199200
onClick={() => window.location.href = `/dashboard/profile/${msg.user_id}`}
200201
>
201202
{msg.profiles.name}
203+
{msg.profiles.is_sponsor && <SponsorBadge size="sm" count={msg.profiles.sponsor_count || 0} />}
202204
</span>
203205
)}
204206

pages/DrinksPage.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import Card from "../components/common/Card";
55
import Button from "../components/common/Button";
66
import Modal from "../components/common/Modal";
77
import Input from "../components/common/Input";
8-
import { spotService, paymentService, drinkService, cigaretteService, foodService, drinkBrandService, userDrinkSelectionService } from "../services/database";
8+
import { spotService, paymentService, drinkService, cigaretteService, foodService, drinkBrandService, userDrinkSelectionService, sponsorService } from "../services/database";
99
import { supabase } from "../services/supabase";
1010
import { Plus, ThumbsUp, Trash2, Loader2, Image as ImageIcon, X, Camera, ShoppingCart, Minus, Check, Wine, Search, Menu, ArrowLeft, Star, Edit, Utensils, Download } from "lucide-react";
1111
import ShinyText from "../components/common/ShinyText";
12+
import SponsorBadge from "../components/common/SponsorBadge";
1213

1314
const DrinksPage: React.FC = () => {
1415
const { profile } = useAuth();
@@ -21,6 +22,7 @@ const DrinksPage: React.FC = () => {
2122
const [loading, setLoading] = useState(true);
2223
const [isPaid, setIsPaid] = useState(false);
2324
const [pageError, setPageError] = useState<string | null>(null);
25+
const [spotSponsor, setSpotSponsor] = useState<any>(null);
2426

2527
// UI State
2628
const [activeSection, setActiveSection] = useState<'browse' | 'checkout' | 'detail'>('browse');
@@ -130,6 +132,8 @@ const DrinksPage: React.FC = () => {
130132
setSpot(spotData);
131133

132134
if (spotData) {
135+
const sponsor = await sponsorService.getSponsor(spotData.id);
136+
setSpotSponsor(sponsor);
133137
let userId: string;
134138
try {
135139
userId = await getUserIdAsUUID(profile.id);
@@ -191,6 +195,7 @@ const DrinksPage: React.FC = () => {
191195
} else {
192196
setDrinks([]);
193197
setIsPaid(false);
198+
setSpotSponsor(null);
194199
}
195200
} catch (error: any) {
196201
console.error("Error loading drinks data:", error);
@@ -620,7 +625,7 @@ const DrinksPage: React.FC = () => {
620625
}
621626
};
622627

623-
const totalCartAmount = userSelections.reduce((sum, sel) => sum + sel.total_price, 0);
628+
const totalCartAmount = spotSponsor ? 0 : userSelections.reduce((sum, sel) => sum + sel.total_price, 0);
624629
const cartItemCount = userSelections.reduce((sum, sel) => sum + sel.quantity, 0);
625630

626631
const categories = ['all', 'wine', 'beer', 'spirits'];
@@ -741,6 +746,14 @@ const DrinksPage: React.FC = () => {
741746
URL.revokeObjectURL(url);
742747
};
743748

749+
750+
const sponsorBanner = spotSponsor ? (
751+
<div className="mb-4 p-3 rounded-xl bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-between gap-3">
752+
<p className="text-sm text-yellow-100">🎉 This spot is sponsored by <strong>{spotSponsor.sponsor?.name || 'A Bro'}</strong>! Everyone pays Rs.0</p>
753+
<SponsorBadge size="sm" showLabel={false} count={spotSponsor.sponsor?.sponsor_count || 0} />
754+
</div>
755+
) : null;
756+
744757
// Safety check: don't render if profile is not available yet
745758
if (!profile) {
746759
return (
@@ -803,6 +816,7 @@ const DrinksPage: React.FC = () => {
803816
return (
804817
<div className="space-y-6 pb-20 max-w-6xl mx-auto px-4">
805818
<h1 className="text-2xl md:text-3xl font-bold">Bar Menu</h1>
819+
{sponsorBanner}
806820
<Card className="p-8 text-center">
807821
<p className="text-gray-400 mb-4">You need to complete payment first to access the bar.</p>
808822
<Button onClick={() => window.location.href = '/dashboard/payment'}>Go to Payment</Button>
@@ -1705,6 +1719,7 @@ const DrinksPage: React.FC = () => {
17051719
</div>
17061720

17071721
<div className="max-w-7xl mx-auto px-4 py-6">
1722+
{sponsorBanner}
17081723
<h1 className="text-2xl md:text-3xl font-bold mb-6">Checkout</h1>
17091724

17101725
{/* Order List */}

pages/HomePage.tsx

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,23 @@ import {
1010
InvitationStatus,
1111
PaymentStatus,
1212
UserRole,
13+
SpotSponsor,
1314
} from "../types";
1415
import Card from "../components/common/Card";
1516
import Button from "../components/common/Button";
1617
import Modal from "../components/common/Modal";
1718
import Input from "../components/common/Input";
1819
import GlowButton from "../components/common/GlowButton";
1920
import Textarea from "../components/common/Textarea";
20-
import { spotService, invitationService, paymentService, notificationService, profileService } from "../services/database";
21+
import { spotService, invitationService, paymentService, notificationService, profileService, sponsorService } from "../services/database";
2122
import { supabase } from "../services/supabase";
2223
import { checkDatabaseSetup, getSetupInstructions } from "../services/dbCheck";
2324
import { useNotifications } from "../contexts/NotificationsContext";
2425
import { format } from "date-fns";
2526
import ShinyText from "../components/common/ShinyText";
2627
import GradientText from "../components/common/GradientText";
2728
import StarBorder from "../components/common/StarBorder";
29+
import SponsorBadge from "../components/common/SponsorBadge";
2830

2931
declare const google: any;
3032

@@ -47,6 +49,10 @@ const HomePage: React.FC = () => {
4749
feedback: '',
4850
});
4951
const [dbSetupError, setDbSetupError] = useState<string | null>(null);
52+
const [spotSponsor, setSpotSponsor] = useState<SpotSponsor | null>(null);
53+
const [allProfiles, setAllProfiles] = useState<any[]>([]);
54+
const [isSponsorModalOpen, setIsSponsorModalOpen] = useState(false);
55+
const [sponsorForm, setSponsorForm] = useState({ sponsor_id: '', amount_covered: '', message: '' });
5056

5157
const [newSpotData, setNewSpotData] = useState({
5258
location: "",
@@ -85,11 +91,19 @@ const HomePage: React.FC = () => {
8591
setSpot(spotData);
8692

8793
if (spotData) {
88-
const inv = await invitationService.getInvitations(spotData.id);
94+
const [inv, sponsor] = await Promise.all([
95+
invitationService.getInvitations(spotData.id),
96+
sponsorService.getSponsor(spotData.id),
97+
]);
8998
setInvitations(inv);
99+
setSpotSponsor(sponsor);
90100
} else {
91101
setInvitations([]);
102+
setSpotSponsor(null);
92103
}
104+
105+
const profiles = await profileService.getAllProfiles();
106+
setAllProfiles(profiles || []);
93107
} catch (err: any) {
94108
console.error("Error fetching data:", err);
95109
if (err.message?.includes('does not exist') || err.message?.includes('relation')) {
@@ -109,6 +123,7 @@ const HomePage: React.FC = () => {
109123
// Set up real-time subscriptions
110124
let spotChannel: any = null;
111125
let invitationChannel: any = null;
126+
let sponsorChannel: any = null;
112127

113128
if (spot) {
114129
// Subscribe to spot changes
@@ -127,6 +142,8 @@ const HomePage: React.FC = () => {
127142
}
128143
});
129144

145+
sponsorChannel = sponsorService.subscribeToSponsors(() => fetchData());
146+
130147
// Subscribe to invitation changes for this spot
131148
invitationChannel = invitationService.subscribeToInvitations(
132149
spot.id,
@@ -158,6 +175,9 @@ const HomePage: React.FC = () => {
158175
if (invitationChannel) {
159176
supabase.removeChannel(invitationChannel);
160177
}
178+
if (sponsorChannel) {
179+
supabase.removeChannel(sponsorChannel);
180+
}
161181
};
162182
}, [fetchData, spot?.id, notify]);
163183

@@ -621,6 +641,36 @@ const HomePage: React.FC = () => {
621641
);
622642
}
623643

644+
645+
const handleAssignSponsor = async (e: React.FormEvent) => {
646+
e.preventDefault();
647+
if (!spot || !sponsorForm.sponsor_id || !sponsorForm.amount_covered) return;
648+
649+
try {
650+
await sponsorService.sponsorSpot({
651+
spot_id: spot.id,
652+
sponsor_id: sponsorForm.sponsor_id,
653+
amount_covered: Number(sponsorForm.amount_covered),
654+
message: sponsorForm.message,
655+
});
656+
setIsSponsorModalOpen(false);
657+
setSponsorForm({ sponsor_id: '', amount_covered: '', message: '' });
658+
await fetchData();
659+
} catch (error: any) {
660+
alert(`Failed to assign sponsor: ${error.message || 'Please try again.'}`);
661+
}
662+
};
663+
664+
const handleRemoveSponsor = async () => {
665+
if (!spot) return;
666+
try {
667+
await sponsorService.removeSponsor(spot.id);
668+
await fetchData();
669+
} catch (error: any) {
670+
alert(`Failed to remove sponsor: ${error.message || 'Please try again.'}`);
671+
}
672+
};
673+
624674
/* ----------------------------- UI ----------------------------- */
625675

626676
return (
@@ -686,9 +736,23 @@ const HomePage: React.FC = () => {
686736
day: 'numeric'
687737
})} at {spot.timing}
688738
</p>
739+
{spotSponsor && (
740+
<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">
741+
<span>🎉 This spot is sponsored by <strong>{spotSponsor.sponsor?.name || 'A Bro'}</strong>!</span>
742+
<SponsorBadge size="sm" showLabel={false} count={spotSponsor.sponsor?.sponsor_count || 0} />
743+
</div>
744+
)}
689745
</div>
690746
{isAdmin && (
691747
<div className="flex gap-2">
748+
<Button size="sm" variant="secondary" onClick={() => setIsSponsorModalOpen(true)}>
749+
Add Sponsor
750+
</Button>
751+
{spotSponsor && (
752+
<Button size="sm" variant="secondary" onClick={handleRemoveSponsor}>
753+
Remove Sponsor
754+
</Button>
755+
)}
692756
<Button
693757
size="sm"
694758
variant="secondary"
@@ -895,6 +959,42 @@ const HomePage: React.FC = () => {
895959
</>
896960
)}
897961

962+
963+
<Modal
964+
isOpen={isSponsorModalOpen}
965+
onClose={() => setIsSponsorModalOpen(false)}
966+
title="Add Sponsor"
967+
>
968+
<form onSubmit={handleAssignSponsor} className="space-y-4">
969+
<div>
970+
<label className="text-sm text-zinc-400">Sponsor</label>
971+
<select
972+
value={sponsorForm.sponsor_id}
973+
onChange={(e) => setSponsorForm(prev => ({ ...prev, sponsor_id: e.target.value }))}
974+
className="w-full mt-2 bg-zinc-900 border border-white/10 rounded-lg p-3"
975+
required
976+
>
977+
<option value="">Select member</option>
978+
{allProfiles.map((member) => (
979+
<option key={member.id} value={member.id}>{member.name} (@{member.username})</option>
980+
))}
981+
</select>
982+
</div>
983+
<Input
984+
label="Amount Covered"
985+
type="number"
986+
value={sponsorForm.amount_covered}
987+
onChange={(e) => setSponsorForm(prev => ({ ...prev, amount_covered: e.target.value }))}
988+
/>
989+
<Textarea
990+
label="Message"
991+
value={sponsorForm.message}
992+
onChange={(e) => setSponsorForm(prev => ({ ...prev, message: e.target.value }))}
993+
/>
994+
<Button type="submit" className="w-full">Assign Sponsor</Button>
995+
</form>
996+
</Modal>
997+
898998
{/* CREATE SPOT MODAL */}
899999
<Modal
9001000
isOpen={isCreateSpotModalOpen}

0 commit comments

Comments
 (0)