feat: Add testimonials and update Gemini model
Integrates a testimonial system to display user results and feedback. This includes: - Adding a new `testimonials` collection in Firestore. - Implementing frontend components for displaying and submitting testimonials. - Modifying the Gemini service to use the `gemini-3.1-pro-preview` model for enhanced analysis. - Updating the `PlansView` with a new WhatsApp contact number. - Enhancing the `AdminDashboard` to include testimonial creation capabilities.
This commit is contained in:
@@ -37,6 +37,22 @@
|
||||
"result": { "type": "string" }
|
||||
},
|
||||
"required": ["userId", "pair", "decision", "timestamp"]
|
||||
},
|
||||
"Testimonial": {
|
||||
"title": "User Testimonial",
|
||||
"description": "Public review left by a user.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userId": { "type": "string" },
|
||||
"userName": { "type": "string" },
|
||||
"text": { "type": "string" },
|
||||
"timestamp": { "type": "integer" },
|
||||
"imageUrls": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["userId", "userName", "text", "timestamp"]
|
||||
}
|
||||
},
|
||||
"firestore": {
|
||||
@@ -47,6 +63,10 @@
|
||||
"signals/{signalId}": {
|
||||
"schema": "Signal",
|
||||
"description": "Generated signals."
|
||||
},
|
||||
"testimonials/{testimonialId}": {
|
||||
"schema": "Testimonial",
|
||||
"description": "Public user testimonials."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +93,23 @@ service cloud.firestore {
|
||||
|
||||
allow update, delete: if isDbAdmin();
|
||||
}
|
||||
|
||||
// --- Testimonials Collection ---
|
||||
match /testimonials/{testimonialId} {
|
||||
allow read: if true;
|
||||
|
||||
function isValidTestimonial(data) {
|
||||
return data.keys().hasAll(['userId', 'userName', 'text', 'timestamp', 'imageUrls'])
|
||||
&& (data.userId == request.auth.uid || isDbAdmin())
|
||||
&& data.userName is string && data.userName.size() <= 100
|
||||
&& data.text is string && data.text.size() <= 500
|
||||
&& data.timestamp is int
|
||||
&& data.imageUrls is list && data.imageUrls.size() <= 10
|
||||
&& (data.imageUrls.size() == 0 || data.imageUrls[0] is string);
|
||||
}
|
||||
|
||||
allow create: if isSignedIn() && isValidTestimonial(incoming());
|
||||
allow update, delete: if isDbAdmin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useState } from 'react';
|
||||
import { collection, addDoc } from 'firebase/firestore';
|
||||
import { db, auth } from '../lib/firebase';
|
||||
import { ImageUploader } from './ImageUploader';
|
||||
|
||||
export const AddTestimonialForm: React.FC = () => {
|
||||
const [text, setText] = useState('');
|
||||
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!auth.currentUser || !text.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await addDoc(collection(db, 'testimonials'), {
|
||||
userId: auth.currentUser.uid,
|
||||
userName: auth.currentUser.email || 'Usuário',
|
||||
text: text,
|
||||
timestamp: Date.now(),
|
||||
imageUrls: imageUrls
|
||||
});
|
||||
setText('');
|
||||
setImageUrls([]);
|
||||
alert('Resultado enviado com sucesso!');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Erro ao enviar resultado.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!auth.currentUser) return null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="glass-card p-6 space-y-4 mt-8 max-w-2xl mx-auto">
|
||||
<h3 className="font-black uppercase tracking-widest text-brand-red">Deixe o seu resultado</h3>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-white focus:outline-none focus:ring-1 focus:ring-brand-red"
|
||||
placeholder="O que achou do QuantScanner?"
|
||||
maxLength={500}
|
||||
/>
|
||||
<ImageUploader onUpload={setImageUrls} />
|
||||
{imageUrls.length > 0 && <p className="text-xs text-green-500">{imageUrls.length} imagem(ns) carregada(s) com sucesso!</p>}
|
||||
<button
|
||||
disabled={submitting}
|
||||
type="submit"
|
||||
className="bg-brand-red text-white py-2 px-4 rounded-lg font-bold hover:bg-opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Enviando...' : 'Enviar Resultado'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, ShieldCheck, Search, Loader2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { collection, onSnapshot, doc, updateDoc } from 'firebase/firestore';
|
||||
import { db, handleFirestoreError, OperationType } from '../lib/firebase';
|
||||
import { collection, onSnapshot, doc, updateDoc, addDoc } from 'firebase/firestore';
|
||||
import { db, handleFirestoreError, OperationType, auth } from '../lib/firebase';
|
||||
import { ImageUploader } from './ImageUploader';
|
||||
|
||||
interface UserProfile {
|
||||
uid: string;
|
||||
@@ -19,6 +20,34 @@ export const AdminDashboard: React.FC = () => {
|
||||
const [users, setUsers] = useState<UserProfile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [testUser, setTestUser] = useState('');
|
||||
const [testText, setTestText] = useState('');
|
||||
const [testImageUrls, setTestImageUrls] = useState<string[]>([]);
|
||||
const [submittingTestimonial, setSubmittingTestimonial] = useState(false);
|
||||
|
||||
const handleCreateTestimonial = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!testUser.trim() || !testText.trim() || !auth.currentUser) return;
|
||||
setSubmittingTestimonial(true);
|
||||
try {
|
||||
await addDoc(collection(db, 'testimonials'), {
|
||||
userId: auth.currentUser.uid,
|
||||
userName: testUser,
|
||||
text: testText,
|
||||
timestamp: Date.now(),
|
||||
imageUrls: testImageUrls
|
||||
});
|
||||
setTestUser('');
|
||||
setTestText('');
|
||||
setTestImageUrls([]);
|
||||
alert('Resultado postado com sucesso!');
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
alert('Erro ao postar');
|
||||
} finally {
|
||||
setSubmittingTestimonial(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onSnapshot(collection(db, 'users'), (snapshot) => {
|
||||
@@ -105,6 +134,36 @@ export const AdminDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateTestimonial} className="glass-card p-6 space-y-4">
|
||||
<h3 className="font-black uppercase tracking-widest text-brand-red text-sm">Postar Resultado como Admin</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nome do Usuário"
|
||||
className="bg-white/5 border border-white/10 rounded-lg p-3 text-white focus:outline-none focus:ring-1 focus:ring-brand-red"
|
||||
value={testUser}
|
||||
onChange={(e) => setTestUser(e.target.value)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<ImageUploader onUpload={setTestImageUrls} />
|
||||
{testImageUrls.length > 0 && <p className="text-xs text-green-500">{testImageUrls.length} imagem(ns) carregada(s)!</p>}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="Texto do Resultado"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-white focus:outline-none focus:ring-1 focus:ring-brand-red"
|
||||
value={testText}
|
||||
onChange={(e) => setTestText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
disabled={submittingTestimonial}
|
||||
type="submit"
|
||||
className="bg-brand-red text-white py-2 px-4 rounded-lg font-bold hover:bg-opacity-90 disabled:opacity-50 w-full"
|
||||
>
|
||||
{submittingTestimonial ? 'Postando...' : 'Postar Resultado'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<Loader2 size={32} className="text-brand-red animate-spin" />
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
||||
import { storage, auth } from '../lib/firebase';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
onUpload: (urls: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ImageUploader: React.FC<ImageUploaderProps> = ({ onUpload, className }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files || e.target.files.length === 0 || !auth.currentUser) return;
|
||||
|
||||
const files = Array.from(e.target.files) as File[];
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const uploadPromises = files.map(async (file) => {
|
||||
const storageRef = ref(storage, `testimonials/${auth.currentUser!.uid}/${Date.now()}_${file.name}`);
|
||||
await uploadBytes(storageRef, file);
|
||||
return getDownloadURL(storageRef);
|
||||
});
|
||||
const urls = await Promise.all(uploadPromises);
|
||||
onUpload(urls);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Erro ao fazer upload das imagens.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-white focus:outline-none focus:ring-1 focus:ring-brand-red text-sm"
|
||||
/>
|
||||
{uploading && <p className="text-xs text-brand-red mt-1">Enviando imagens...</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { TrendingUp, ShieldCheck, Zap, BarChart3, Globe, ChevronRight, CheckCircle2 } from 'lucide-react';
|
||||
import { collection, query, onSnapshot, orderBy } from 'firebase/firestore';
|
||||
import { db } from '../lib/firebase';
|
||||
import { MarketTicker } from './MarketTicker';
|
||||
import { AddTestimonialForm } from './AddTestimonialForm';
|
||||
|
||||
interface LandingPageProps {
|
||||
onGetStarted: () => void;
|
||||
@@ -9,6 +12,16 @@ interface LandingPageProps {
|
||||
}
|
||||
|
||||
export const LandingPage: React.FC<LandingPageProps> = ({ onGetStarted, onViewPlans }) => {
|
||||
const [testimonials, setTestimonials] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = query(collection(db, 'testimonials'), orderBy('timestamp', 'desc'));
|
||||
const unsubscribe = onSnapshot(q, (snapshot) => {
|
||||
setTestimonials(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })));
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-brand-dark text-white overflow-x-hidden">
|
||||
{/* Navbar */}
|
||||
@@ -260,12 +273,19 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGetStarted, onViewPl
|
||||
</div>
|
||||
|
||||
<div className="glass-card p-8 border-brand-red/10 space-y-4">
|
||||
<h3 className="font-black uppercase text-xl">Testemunhos</h3>
|
||||
<h3 className="font-black uppercase text-xl">Resultados</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm text-zinc-400">
|
||||
<p><em>"Desde que comecei a usar o IA QuantScanner, minhas entradas ficaram muito mais precisas. Reduzi minhas perdas e aumentei meus lucros em poucas semanas."</em> <br/>– **Carlos M.**</p>
|
||||
<p><em>"A velocidade com que o sistema identifica oportunidades é impressionante. É como ter um analista profissional trabalhando para mim 24h."</em> <br/>– **João D.**</p>
|
||||
{testimonials.map((t, i) => (
|
||||
<div key={i} className="space-y-4">
|
||||
{t.imageUrls && t.imageUrls.map((url: string, index: number) => (
|
||||
<img key={index} src={url} alt={`Resultado ${index}`} className="w-full rounded-lg mb-2" referrerPolicy="no-referrer" />
|
||||
))}
|
||||
<p><em>{t.text}</em> <br/>– **{t.userName.split('@')[0]}**</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AddTestimonialForm />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export const PlansView: React.FC<PlansViewProps> = ({ isUnauthenticated, onGetSt
|
||||
onGetStarted();
|
||||
} else {
|
||||
// Handle payment or subscription logic for logged in users
|
||||
window.open('https://wa.me/258840000000?text=Ol%C3%A1%2C%20gostaria%20de%20assinar%20o%20plano%20do%20QuantScan', '_blank');
|
||||
window.open('https://wa.me/258869976193?text=Ol%C3%A1%2C%20gostaria%20de%20assinar%20o%20plano%20do%20QuantScan', '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AnalysisResponse, SignalType } from "../types";
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || '' });
|
||||
|
||||
export const analyzeForexChart = async (imageBase64: string, userNotes?: string, preferredMode?: 'Técnico' | 'Fundamental' | 'Híbrido'): Promise<AnalysisResponse> => {
|
||||
const model = "gemini-3-flash-preview";
|
||||
const model = "gemini-3.1-pro-preview";
|
||||
|
||||
const systemInstruction = `
|
||||
Você é o QuantScan IA, um sistema avançado de análise de mercado financeiro com inteligência institucional.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
rules_version = '2';
|
||||
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
function isSignedIn() { return request.auth != null; }
|
||||
function isDbAdmin() {
|
||||
return isSignedIn() && firestore.get(/databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin == true;
|
||||
}
|
||||
|
||||
match /testimonials/{userId}/{fileName} {
|
||||
allow read: if true;
|
||||
allow create, update: if isSignedIn() && (request.auth.uid == userId || isDbAdmin())
|
||||
&& request.resource.size < 5 * 1024 * 1024 // 5MB limit
|
||||
&& request.resource.contentType.matches('image/.*');
|
||||
allow delete: if isSignedIn() && (request.auth.uid == userId || isDbAdmin());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user