Skip to content

Commit 0c92939

Browse files
authored
Merge pull request #35 from fuzziecoder/codex/fix-backend-issues-for-sponsor-system
Add Sponsor system: DB migration, service layer, and app-wide badge UI
2 parents 02043b6 + bb8fa3f commit 0c92939

10 files changed

Lines changed: 405 additions & 9 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
@@ -128,6 +143,8 @@ const HomePage: React.FC = () => {
128143
}
129144
});
130145

146+
sponsorChannel = sponsorService.subscribeToSponsors(() => fetchData());
147+
131148
// Subscribe to invitation changes for this spot
132149
invitationChannel = invitationService.subscribeToInvitations(
133150
spot.id,
@@ -160,6 +177,9 @@ const HomePage: React.FC = () => {
160177
if (invitationChannel) {
161178
supabase.removeChannel(invitationChannel);
162179
}
180+
if (sponsorChannel) {
181+
supabase.removeChannel(sponsorChannel);
182+
}
163183
};
164184
}, [fetchData, spot?.id, notify]);
165185

@@ -631,6 +651,36 @@ const HomePage: React.FC = () => {
631651
);
632652
}
633653

654+
655+
const handleAssignSponsor = async (e: React.FormEvent) => {
656+
e.preventDefault();
657+
if (!spot || !sponsorForm.sponsor_id || !sponsorForm.amount_covered) return;
658+
659+
try {
660+
await sponsorService.sponsorSpot({
661+
spot_id: spot.id,
662+
sponsor_id: sponsorForm.sponsor_id,
663+
amount_covered: Number(sponsorForm.amount_covered),
664+
message: sponsorForm.message,
665+
});
666+
setIsSponsorModalOpen(false);
667+
setSponsorForm({ sponsor_id: '', amount_covered: '', message: '' });
668+
await fetchData();
669+
} catch (error: any) {
670+
alert(`Failed to assign sponsor: ${error.message || 'Please try again.'}`);
671+
}
672+
};
673+
674+
const handleRemoveSponsor = async () => {
675+
if (!spot) return;
676+
try {
677+
await sponsorService.removeSponsor(spot.id);
678+
await fetchData();
679+
} catch (error: any) {
680+
alert(`Failed to remove sponsor: ${error.message || 'Please try again.'}`);
681+
}
682+
};
683+
634684
/* ----------------------------- UI ----------------------------- */
635685

636686
return (
@@ -696,9 +746,23 @@ const HomePage: React.FC = () => {
696746
day: 'numeric'
697747
})} at {spot.timing}
698748
</p>
749+
{spotSponsor && (
750+
<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">
751+
<span>🎉 This spot is sponsored by <strong>{spotSponsor.sponsor?.name || 'A Bro'}</strong>!</span>
752+
<SponsorBadge size="sm" showLabel={false} count={spotSponsor.sponsor?.sponsor_count || 0} />
753+
</div>
754+
)}
699755
</div>
700756
{isAdmin && (
701757
<div className="flex gap-2">
758+
<Button size="sm" variant="secondary" onClick={() => setIsSponsorModalOpen(true)}>
759+
Add Sponsor
760+
</Button>
761+
{spotSponsor && (
762+
<Button size="sm" variant="secondary" onClick={handleRemoveSponsor}>
763+
Remove Sponsor
764+
</Button>
765+
)}
702766
<Button
703767
size="sm"
704768
variant="secondary"
@@ -905,6 +969,42 @@ const HomePage: React.FC = () => {
905969
</>
906970
)}
907971

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

0 commit comments

Comments
 (0)