Skip to content

Commit 707a202

Browse files
committed
Add AI party planner recommendations to drinks page
1 parent 0c92939 commit 707a202

3 files changed

Lines changed: 174 additions & 3 deletions

File tree

pages/DrinksPage.tsx

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import Modal from "../components/common/Modal";
77
import Input from "../components/common/Input";
88
import { spotService, paymentService, drinkService, cigaretteService, foodService, drinkBrandService, userDrinkSelectionService, sponsorService } from "../services/database";
99
import { supabase } from "../services/supabase";
10-
import { Plus, ThumbsUp, Trash2, Loader2, Image as ImageIcon, X, Camera, ShoppingCart, Minus, Check, Wine, Search, Menu, ArrowLeft, Star, Edit, Utensils, Download } from "lucide-react";
10+
import { Plus, ThumbsUp, Trash2, Loader2, Image as ImageIcon, X, Camera, ShoppingCart, Minus, Check, Wine, Search, Menu, ArrowLeft, Star, Edit, Utensils, Download, Sparkles } from "lucide-react";
1111
import ShinyText from "../components/common/ShinyText";
1212
import SponsorBadge from "../components/common/SponsorBadge";
13+
import { getPartyAssistantSuggestions, PartyAssistantSuggestion } from "../services/partyAssistant";
1314

1415
const DrinksPage: React.FC = () => {
1516
const { profile } = useAuth();
@@ -56,6 +57,9 @@ const DrinksPage: React.FC = () => {
5657
const [priceInput, setPriceInput] = useState("");
5758
const [currentUserUUID, setCurrentUserUUID] = useState<string | null>(null);
5859
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
60+
const [aiPrompt, setAiPrompt] = useState("Plan for a chill Friday night with balanced drinks and snacks");
61+
const [aiSuggestions, setAiSuggestions] = useState<PartyAssistantSuggestion[]>([]);
62+
const [isAiLoading, setIsAiLoading] = useState(false);
5963

6064
const isAdmin = profile?.role === UserRole.ADMIN;
6165

@@ -652,6 +656,25 @@ const DrinksPage: React.FC = () => {
652656
return userVotedDrinks.has(drink.id);
653657
};
654658

659+
const handleGenerateAiSuggestions = async () => {
660+
setIsAiLoading(true);
661+
try {
662+
const suggestions = await getPartyAssistantSuggestions({
663+
prompt: aiPrompt,
664+
budget: spot?.budget,
665+
drinks,
666+
foods,
667+
cigarettes,
668+
});
669+
setAiSuggestions(suggestions);
670+
} catch (error) {
671+
console.error("Failed to generate AI suggestions", error);
672+
setAiSuggestions([]);
673+
} finally {
674+
setIsAiLoading(false);
675+
}
676+
};
677+
655678
// Export data to Excel (CSV format)
656679
const exportToExcel = () => {
657680
if (!isAdmin) return;
@@ -877,6 +900,43 @@ const DrinksPage: React.FC = () => {
877900
/>
878901
</div>
879902

903+
<Card className="p-4 bg-gradient-to-br from-indigo-500/15 to-fuchsia-500/10 border border-indigo-500/30">
904+
<div className="flex items-start justify-between gap-3 mb-3">
905+
<div>
906+
<p className="text-sm text-indigo-200 font-semibold flex items-center gap-2">
907+
<Sparkles size={16} />
908+
AI Party Planner
909+
</p>
910+
<p className="text-xs text-zinc-300 mt-1">Uses Gemini when <code>VITE_GEMINI_API_KEY</code> is configured; otherwise falls back to smart local recommendations.</p>
911+
</div>
912+
<button
913+
onClick={handleGenerateAiSuggestions}
914+
disabled={isAiLoading}
915+
className="px-3 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium disabled:opacity-60"
916+
>
917+
{isAiLoading ? "Thinking..." : "Generate"}
918+
</button>
919+
</div>
920+
<Input
921+
value={aiPrompt}
922+
onChange={(e) => setAiPrompt(e.target.value)}
923+
placeholder="Example: Build a premium menu for 8 people under ₹3500"
924+
/>
925+
926+
{aiSuggestions.length > 0 && (
927+
<div className="grid md:grid-cols-3 gap-3 mt-4">
928+
{aiSuggestions.map((suggestion) => (
929+
<div key={suggestion.title} className="rounded-xl border border-zinc-700/60 bg-black/30 p-3">
930+
<p className="font-semibold text-white text-sm">{suggestion.title}</p>
931+
<p className="text-xs text-zinc-300 mt-1">{suggestion.reason}</p>
932+
<p className="text-xs text-emerald-300 mt-2">Estimated: ₹{suggestion.estimatedCost}</p>
933+
<p className="text-xs text-zinc-400 mt-2">{suggestion.picks.join(" • ")}</p>
934+
</div>
935+
))}
936+
</div>
937+
)}
938+
</Card>
939+
880940
{/* Type Toggle Pills */}
881941
<div className="flex gap-2">
882942
{(['drinks', 'food', 'cigarette'] as const).map((type) => (
@@ -2312,4 +2372,3 @@ const DrinksPage: React.FC = () => {
23122372
};
23132373

23142374
export default DrinksPage;
2315-

services/partyAssistant.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Cigarette, Drink, Food } from "../types";
2+
3+
interface PartyAssistantInput {
4+
prompt: string;
5+
budget?: number;
6+
drinks: Drink[];
7+
foods: Food[];
8+
cigarettes: Cigarette[];
9+
}
10+
11+
export interface PartyAssistantSuggestion {
12+
title: string;
13+
reason: string;
14+
estimatedCost: number;
15+
picks: string[];
16+
}
17+
18+
const parseCurrency = (value: unknown): number => {
19+
const num = Number(value);
20+
return Number.isFinite(num) ? Math.max(0, num) : 0;
21+
};
22+
23+
const buildFallbackSuggestions = ({ prompt, drinks, foods, cigarettes, budget }: PartyAssistantInput): PartyAssistantSuggestion[] => {
24+
const lowerPrompt = prompt.toLowerCase();
25+
const targetBudget = budget ?? 2000;
26+
27+
const drinkPick = [...drinks]
28+
.sort((a, b) => parseCurrency(a.price) - parseCurrency(b.price))
29+
.slice(0, 2)
30+
.map((item) => item.name);
31+
32+
const foodPick = [...foods]
33+
.sort((a, b) => parseCurrency(a.price) - parseCurrency(b.price))
34+
.slice(0, 2)
35+
.map((item) => item.name);
36+
37+
const cigarettePick = [...cigarettes]
38+
.sort((a, b) => parseCurrency(a.price) - parseCurrency(b.price))
39+
.slice(0, 1)
40+
.map((item) => item.name);
41+
42+
const chillMode = lowerPrompt.includes("chill") || lowerPrompt.includes("casual");
43+
44+
return [
45+
{
46+
title: chillMode ? "Chill Starter Combo" : "Balanced Party Combo",
47+
reason: "Generated locally using price-optimized item ranking because AI key is unavailable.",
48+
estimatedCost: Math.min(targetBudget * 0.5, 1200),
49+
picks: [...drinkPick, ...foodPick],
50+
},
51+
{
52+
title: "Night-Long Upgrade",
53+
reason: "Keeps energy balanced with one snack + one premium beverage + optional smoke pick.",
54+
estimatedCost: Math.min(targetBudget * 0.8, 2200),
55+
picks: [...drinkPick.slice(0, 1), ...foodPick.slice(0, 1), ...cigarettePick],
56+
},
57+
];
58+
};
59+
60+
export const getPartyAssistantSuggestions = async (
61+
input: PartyAssistantInput
62+
): Promise<PartyAssistantSuggestion[]> => {
63+
const apiKey = import.meta.env.VITE_GEMINI_API_KEY;
64+
65+
if (!apiKey) {
66+
return buildFallbackSuggestions(input);
67+
}
68+
69+
const payload = {
70+
prompt: `You are a party planning AI. Return JSON only with format: {"suggestions":[{"title":"","reason":"","estimatedCost":0,"picks":["item"]}]}. Keep max 3 suggestions.\nPrompt:${input.prompt}\nBudget:${input.budget ?? "unknown"}\nDrinks:${JSON.stringify(input.drinks.slice(0, 12))}\nFoods:${JSON.stringify(input.foods.slice(0, 12))}\nCigarettes:${JSON.stringify(input.cigarettes.slice(0, 8))}`,
71+
};
72+
73+
const response = await fetch(
74+
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`,
75+
{
76+
method: "POST",
77+
headers: { "Content-Type": "application/json" },
78+
body: JSON.stringify({
79+
contents: [{ parts: [{ text: payload.prompt }] }],
80+
}),
81+
}
82+
);
83+
84+
if (!response.ok) {
85+
return buildFallbackSuggestions(input);
86+
}
87+
88+
const data = await response.json();
89+
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
90+
91+
if (!text) {
92+
return buildFallbackSuggestions(input);
93+
}
94+
95+
try {
96+
const jsonBlock = text.match(/\{[\s\S]*\}/)?.[0] || text;
97+
const parsed = JSON.parse(jsonBlock);
98+
const suggestions = parsed?.suggestions;
99+
if (!Array.isArray(suggestions)) {
100+
return buildFallbackSuggestions(input);
101+
}
102+
103+
return suggestions.slice(0, 3).map((item: any) => ({
104+
title: String(item.title || "AI Suggestion"),
105+
reason: String(item.reason || "AI-generated recommendation"),
106+
estimatedCost: parseCurrency(item.estimatedCost),
107+
picks: Array.isArray(item.picks) ? item.picks.map((pick: unknown) => String(pick)).slice(0, 6) : [],
108+
}));
109+
} catch {
110+
return buildFallbackSuggestions(input);
111+
}
112+
};

vite-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
interface ImportMetaEnv {
44
readonly VITE_SUPABASE_URL: string;
55
readonly VITE_SUPABASE_ANON_KEY: string;
6-
readonly GEMINI_API_KEY?: string;
6+
readonly VITE_GEMINI_API_KEY?: string;
77
}
88

99
interface ImportMeta {

0 commit comments

Comments
 (0)