Skip to content

Commit c02d89c

Browse files
authored
Merge pull request #1 from avila2026/copilot/identify-improvements-to-code
perf: fix AI singleton, cache TTL/eviction, React.memo on ProductCard, immutable cart, scroll via useEffect
2 parents 47df29e + a167935 commit c02d89c

File tree

6 files changed

+90
-39
lines changed

6 files changed

+90
-39
lines changed

App.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66

7-
import React, { useState } from 'react';
7+
import React, { useState, useEffect, useRef } from 'react';
88
import Navbar from './components/Navbar';
99
import Hero from './components/Hero';
1010
import ProductGrid from './components/ProductGrid';
@@ -25,16 +25,26 @@ function App() {
2525
const [view, setView] = useState<ViewState>({ type: 'home' });
2626
const [cartItems, setCartItems] = useState<Product[]>([]);
2727
const [isCartOpen, setIsCartOpen] = useState(false);
28+
// Stores the section id to scroll to after a view change
29+
const pendingScrollRef = useRef<string | null>(null);
30+
31+
// Scroll to the pending section once the home view has mounted
32+
useEffect(() => {
33+
if (view.type === 'home' && pendingScrollRef.current !== null) {
34+
const targetId = pendingScrollRef.current;
35+
pendingScrollRef.current = null;
36+
scrollToSection(targetId);
37+
}
38+
}, [view]);
2839

2940
// Handle navigation (clicks on Navbar or Footer links)
3041
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
3142
e.preventDefault();
3243

33-
// If we are not home, go home first
44+
// If we are not home, go home first and defer the scroll to the useEffect above
3445
if (view.type !== 'home') {
46+
pendingScrollRef.current = targetId;
3547
setView({ type: 'home' });
36-
// Allow state update to render Home before scrolling
37-
setTimeout(() => scrollToSection(targetId), 0);
3848
} else {
3949
scrollToSection(targetId);
4050
}
@@ -72,9 +82,7 @@ function App() {
7282
};
7383

7484
const removeFromCart = (index: number) => {
75-
const newItems = [...cartItems];
76-
newItems.splice(index, 1);
77-
setCartItems(newItems);
85+
setCartItems(prev => prev.filter((_, i) => i !== index));
7886
};
7987

8088
return (
@@ -109,8 +117,8 @@ function App() {
109117
<ProductDetail
110118
product={view.product}
111119
onBack={() => {
120+
pendingScrollRef.current = 'products';
112121
setView({ type: 'home' });
113-
setTimeout(() => scrollToSection('products'), 50);
114122
}}
115123
onAddToCart={addToCart}
116124
/>

components/ImageGenerator.tsx

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useState } from 'react';
2-
import { GoogleGenAI } from "@google/genai";
2+
import { GeminiAdapter } from '../services/aiAdapter';
33

44
interface ImageGeneratorProps {
55
onImageGenerated: (imageUrl: string) => void;
@@ -13,27 +13,9 @@ const ImageGenerator: React.FC<ImageGeneratorProps> = ({ onImageGenerated }) =>
1313
const generateImage = async () => {
1414
setLoading(true);
1515
try {
16-
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
17-
const response = await ai.models.generateContent({
18-
model: 'gemini-3.1-flash-image-preview',
19-
contents: {
20-
parts: [{ text: prompt }],
21-
},
22-
config: {
23-
imageConfig: {
24-
aspectRatio: aspectRatio,
25-
imageSize: "1K"
26-
},
27-
},
28-
});
29-
30-
for (const part of response.candidates[0].content.parts) {
31-
if (part.inlineData) {
32-
const base64EncodeString = part.inlineData.data;
33-
const imageUrl = `data:image/png;base64,${base64EncodeString}`;
34-
onImageGenerated(imageUrl);
35-
break;
36-
}
16+
const imageUrl = await GeminiAdapter.generateImage(prompt);
17+
if (imageUrl) {
18+
onImageGenerated(imageUrl);
3719
}
3820
} catch (error) {
3921
console.error('Error generating image:', error);

components/ProductCard.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ interface ProductCardProps {
77
onClick: (product: Product) => void;
88
}
99

10-
const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
10+
const ProductCard: React.FC<ProductCardProps> = React.memo(({ product, onClick }) => {
1111
return (
1212
<div className="group flex flex-col gap-6 cursor-pointer" onClick={() => onClick(product)}>
1313
<div className="relative w-full aspect-[4/5] overflow-hidden bg-[#FFE4E6]">
@@ -42,6 +42,8 @@ const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
4242
</div>
4343
</div>
4444
);
45-
};
45+
});
46+
47+
ProductCard.displayName = 'ProductCard';
4648

4749
export default ProductCard;

services/aiAdapter.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ export interface AIAdapter {
1212
generateImage(prompt: string): Promise<string | null>;
1313
}
1414

15-
const getSystemInstruction = () => {
16-
const productContext = PRODUCTS.map(p =>
15+
// Build the system instruction once and reuse it – the product catalog is static.
16+
const SYSTEM_INSTRUCTION: string = (() => {
17+
const productContext = PRODUCTS.map(p =>
1718
`- ${p.name} ($${p.price}): ${p.description}. Features: ${p.features.join(', ')}`
1819
).join('\n');
1920

@@ -26,11 +27,26 @@ const getSystemInstruction = () => {
2627
Answer customer questions about specifications, recommendations, and brand philosophy.
2728
Keep answers concise (under 3 sentences usually) to fit the chat UI.
2829
If asked about products not in the list, gently steer them back to Achadinhos Maternidade products.`;
30+
})();
31+
32+
/**
33+
* Compute a lightweight hash string for a chat history + new message so that
34+
* we avoid calling JSON.stringify on the entire history array on every request.
35+
* Uses a djb2-style algorithm over the serialised text.
36+
*/
37+
const hashChatKey = (history: {role: string, text: string}[], newMessage: string): string => {
38+
let hash = 5381;
39+
const str = history.map(h => `${h.role}:${h.text}`).join('|') + '|' + newMessage;
40+
for (let i = 0; i < str.length; i++) {
41+
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
42+
hash = hash >>> 0; // keep it an unsigned 32-bit integer
43+
}
44+
return `chat_${history.length}_${hash}`;
2945
};
3046

3147
export const GeminiAdapter: AIAdapter = {
3248
sendMessage: async (history, newMessage, mode) => {
33-
const cacheKey = `chat_${JSON.stringify(history)}_${newMessage}`;
49+
const cacheKey = hashChatKey(history, newMessage);
3450
const cachedResponse = cache.get(cacheKey);
3551
if (cachedResponse) return cachedResponse;
3652

@@ -40,7 +56,7 @@ export const GeminiAdapter: AIAdapter = {
4056

4157
const chat = ai.chats.create({
4258
model,
43-
config: { systemInstruction: getSystemInstruction() },
59+
config: { systemInstruction: SYSTEM_INSTRUCTION },
4460
history: history.map(h => ({ role: h.role, parts: [{ text: h.text }] }))
4561
});
4662

services/aiFactory.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ const getApiKey = () => {
1111
return key;
1212
};
1313

14+
// Singleton: reuse a single GoogleGenAI instance across all API calls.
15+
let _instance: GoogleGenAI | null = null;
16+
1417
export const aiFactory = {
15-
getInstance: () => new GoogleGenAI({ apiKey: getApiKey() }),
18+
getInstance: (): GoogleGenAI => {
19+
if (!_instance) {
20+
_instance = new GoogleGenAI({ apiKey: getApiKey() });
21+
}
22+
return _instance;
23+
},
1624
};

services/cache.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,53 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
const CACHE_PREFIX = 'am_cache_';
7+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
8+
const MAX_CACHE_ENTRIES = 50;
9+
10+
interface CacheEntry {
11+
value: string;
12+
expiresAt: number;
13+
}
14+
615
export const cache = {
716
get: (key: string): string | null => {
817
try {
9-
return localStorage.getItem(key);
18+
const raw = localStorage.getItem(CACHE_PREFIX + key);
19+
if (!raw) return null;
20+
const entry: CacheEntry = JSON.parse(raw);
21+
if (Date.now() > entry.expiresAt) {
22+
localStorage.removeItem(CACHE_PREFIX + key);
23+
return null;
24+
}
25+
return entry.value;
1026
} catch (e) {
1127
console.warn("Cache get failed", e);
1228
return null;
1329
}
1430
},
1531
set: (key: string, value: string): void => {
1632
try {
17-
localStorage.setItem(key, value);
33+
// Evict the oldest entry when the cache is full.
34+
const cacheKeys = Object.keys(localStorage).filter(k => k.startsWith(CACHE_PREFIX));
35+
if (cacheKeys.length >= MAX_CACHE_ENTRIES) {
36+
let oldestKey: string | null = null;
37+
let oldestExpiry = Infinity;
38+
for (const k of cacheKeys) {
39+
try {
40+
const raw = localStorage.getItem(k);
41+
if (!raw) continue;
42+
const entry: CacheEntry = JSON.parse(raw);
43+
if ((entry.expiresAt ?? 0) < oldestExpiry) {
44+
oldestExpiry = entry.expiresAt ?? 0;
45+
oldestKey = k;
46+
}
47+
} catch { /* skip malformed entries */ }
48+
}
49+
if (oldestKey) localStorage.removeItem(oldestKey);
50+
}
51+
const entry: CacheEntry = { value, expiresAt: Date.now() + CACHE_TTL_MS };
52+
localStorage.setItem(CACHE_PREFIX + key, JSON.stringify(entry));
1853
} catch (e) {
1954
console.warn("Cache set failed", e);
2055
}

0 commit comments

Comments
 (0)