Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions DATABASE_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
34 changes: 34 additions & 0 deletions components/common/SponsorBadge.tsx
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`}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: Pluralization issue in title attribute.

When count is 1, the title reads "Sponsored 1 spots" instead of "Sponsored 1 spot".

🛠️ Proposed fix
-      title={`Sponsored ${count} spots`}
+      title={`Sponsored ${count} spot${count === 1 ? '' : 's'}`}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
title={`Sponsored ${count} spots`}
title={`Sponsored ${count} spot${count === 1 ? '' : 's'}`}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/common/SponsorBadge.tsx` at line 24, The title string in
SponsorBadge.tsx currently always uses "spots" which causes "Sponsored 1 spots";
update the title expression that uses the count prop/variable (the
title={`Sponsored ${count} spots`} expression) to conditionally pluralize—e.g.
choose "spot" when count === 1 and "spots" otherwise (or use a small pluralize
helper) so the title reads "Sponsored 1 spot" for a single count.

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;
12 changes: 10 additions & 2 deletions contexts/ChatContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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,
},
}));

Expand Down Expand Up @@ -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)
Expand All @@ -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,
},
};

Expand Down
2 changes: 2 additions & 0 deletions pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -199,6 +200,7 @@ const ChatPage: React.FC = () => {
onClick={() => window.location.href = `/dashboard/profile/${msg.user_id}`}
>
{msg.profiles.name}
{msg.profiles.is_sponsor && <SponsorBadge size="sm" count={msg.profiles.sponsor_count || 0} />}
</span>
)}

Expand Down
19 changes: 17 additions & 2 deletions pages/DrinksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -21,6 +22,7 @@ const DrinksPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [isPaid, setIsPaid] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const [spotSponsor, setSpotSponsor] = useState<any>(null);

// UI State
const [activeSection, setActiveSection] = useState<'browse' | 'checkout' | 'detail'>('browse');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -191,6 +195,7 @@ const DrinksPage: React.FC = () => {
} else {
setDrinks([]);
setIsPaid(false);
setSpotSponsor(null);
}
} catch (error: any) {
console.error("Error loading drinks data:", error);
Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -741,6 +746,14 @@ const DrinksPage: React.FC = () => {
URL.revokeObjectURL(url);
};


const sponsorBanner = spotSponsor ? (
<div className="mb-4 p-3 rounded-xl bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-between gap-3">
<p className="text-sm text-yellow-100">🎉 This spot is sponsored by <strong>{spotSponsor.sponsor?.name || 'A Bro'}</strong>! Everyone pays Rs.0</p>
<SponsorBadge size="sm" showLabel={false} count={spotSponsor.sponsor?.sponsor_count || 0} />
</div>
) : null;

// Safety check: don't render if profile is not available yet
if (!profile) {
return (
Expand Down Expand Up @@ -803,6 +816,7 @@ const DrinksPage: React.FC = () => {
return (
<div className="space-y-6 pb-20 max-w-6xl mx-auto px-4">
<h1 className="text-2xl md:text-3xl font-bold">Bar Menu</h1>
{sponsorBanner}
<Card className="p-8 text-center">
<p className="text-gray-400 mb-4">You need to complete payment first to access the bar.</p>
<Button onClick={() => window.location.href = '/dashboard/payment'}>Go to Payment</Button>
Expand Down Expand Up @@ -1705,6 +1719,7 @@ const DrinksPage: React.FC = () => {
</div>

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

{/* Order List */}
Expand Down
104 changes: 102 additions & 2 deletions pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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: "",
Expand Down Expand Up @@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 getAllProfiles called on every fetchData for all users, not just admins

In HomePage.tsx, profileService.getAllProfiles() is called unconditionally inside fetchData for every user, even though the profiles list is only used in the admin-only sponsor modal.

Root Cause and Impact

At pages/HomePage.tsx:105-106, profileService.getAllProfiles() is called outside the if (spotData) block and without any admin check. The comment in services/database.ts:492 says this is "admin only", and the data is only used in the sponsor modal which is gated behind isAdmin.

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
const profiles = await profileService.getAllProfiles();
setAllProfiles(profiles || []);
if (profile?.role === UserRole.ADMIN) {
const profiles = await profileService.getAllProfiles();
setAllProfiles(profiles || []);
}
Open in Devin Review

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')) {
Expand All @@ -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
Expand All @@ -128,6 +143,8 @@ const HomePage: React.FC = () => {
}
});

sponsorChannel = sponsorService.subscribeToSponsors(() => fetchData());

// Subscribe to invitation changes for this spot
invitationChannel = invitationService.subscribeToInvitations(
spot.id,
Expand Down Expand Up @@ -160,6 +177,9 @@ const HomePage: React.FC = () => {
if (invitationChannel) {
supabase.removeChannel(invitationChannel);
}
if (sponsorChannel) {
supabase.removeChannel(sponsorChannel);
}
};
}, [fetchData, spot?.id, notify]);

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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}
Expand Down
Loading