Implement Firebase configuration with environment variables and update related components
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Vite only exposes env vars prefixed with VITE_
|
||||
# Firebase Web config (these values are not private keys, but still treat your .env as local-only)
|
||||
VITE_FIREBASE_API_KEY=
|
||||
VITE_FIREBASE_AUTH_DOMAIN=
|
||||
VITE_FIREBASE_PROJECT_ID=
|
||||
VITE_FIREBASE_STORAGE_BUCKET=
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID=
|
||||
VITE_FIREBASE_APP_ID=
|
||||
VITE_FIREBASE_MEASUREMENT_ID=
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,5 +28,4 @@ pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env*
|
||||
.env
|
||||
15
Dockerfile
15
Dockerfile
@@ -2,6 +2,14 @@ FROM node:22 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_FIREBASE_API_KEY
|
||||
ARG VITE_FIREBASE_AUTH_DOMAIN
|
||||
ARG VITE_FIREBASE_PROJECT_ID
|
||||
ARG VITE_FIREBASE_STORAGE_BUCKET
|
||||
ARG VITE_FIREBASE_MESSAGING_SENDER_ID
|
||||
ARG VITE_FIREBASE_APP_ID
|
||||
ARG VITE_FIREBASE_MEASUREMENT_ID
|
||||
|
||||
# Copy package*.json
|
||||
COPY package*.json .
|
||||
|
||||
@@ -12,6 +20,13 @@ RUN npm install
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
ENV VITE_FIREBASE_API_KEY=$VITE_FIREBASE_API_KEY \
|
||||
VITE_FIREBASE_AUTH_DOMAIN=$VITE_FIREBASE_AUTH_DOMAIN \
|
||||
VITE_FIREBASE_PROJECT_ID=$VITE_FIREBASE_PROJECT_ID \
|
||||
VITE_FIREBASE_STORAGE_BUCKET=$VITE_FIREBASE_STORAGE_BUCKET \
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID=$VITE_FIREBASE_MESSAGING_SENDER_ID \
|
||||
VITE_FIREBASE_APP_ID=$VITE_FIREBASE_APP_ID \
|
||||
VITE_FIREBASE_MEASUREMENT_ID=$VITE_FIREBASE_MEASUREMENT_ID
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
environment:
|
||||
apiKey: ${apiKey}
|
||||
authDomain: ${authDomain}
|
||||
projectId: ${projectId}
|
||||
storageBucket: ${storageBucket}
|
||||
messagingSenderId: ${messagingSenderId}
|
||||
appId: ${appId}
|
||||
measurementId: ${measurementId}
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VITE_FIREBASE_API_KEY: ${VITE_FIREBASE_API_KEY}
|
||||
VITE_FIREBASE_AUTH_DOMAIN: ${VITE_FIREBASE_AUTH_DOMAIN}
|
||||
VITE_FIREBASE_PROJECT_ID: ${VITE_FIREBASE_PROJECT_ID}
|
||||
VITE_FIREBASE_STORAGE_BUCKET: ${VITE_FIREBASE_STORAGE_BUCKET}
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID: ${VITE_FIREBASE_MESSAGING_SENDER_ID}
|
||||
VITE_FIREBASE_APP_ID: ${VITE_FIREBASE_APP_ID}
|
||||
VITE_FIREBASE_MEASUREMENT_ID: ${VITE_FIREBASE_MEASUREMENT_ID}
|
||||
ports:
|
||||
- "80"
|
||||
deploy:
|
||||
replicas: 1
|
||||
replicas: 3
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
rollback_config:
|
||||
|
||||
23
firebase.ts
23
firebase.ts
@@ -1,22 +1 @@
|
||||
// Import the functions you need from the SDKs you need
|
||||
import { initializeApp } from "firebase/app";
|
||||
import { getFirestore } from "firebase/firestore";
|
||||
// TODO: Add SDKs for Firebase products that you want to use
|
||||
// https://firebase.google.com/docs/web/setup#available-libraries
|
||||
|
||||
// Your web app's Firebase configuration
|
||||
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
|
||||
const firebaseConfig = {
|
||||
apiKey: process.env.apiKey,
|
||||
authDomain: process.env.authDomain,
|
||||
projectId: process.env.projectId,
|
||||
storageBucket: process.env.storageBucket,
|
||||
messagingSenderId: process.env.messagingSenderId,
|
||||
appId: process.env.appId,
|
||||
measurementId: process.env.measurementId
|
||||
};
|
||||
|
||||
// Initialize Firebase
|
||||
const app = initializeApp(firebaseConfig);
|
||||
const db = getFirestore(app);
|
||||
export { app, db };
|
||||
export { app, db } from "./src/firebase.ts";
|
||||
116
src/App.tsx
116
src/App.tsx
@@ -11,6 +11,7 @@ import AthleticSection from "./components/AthleticsSection";
|
||||
import EntrepreuneurshipSection from "./components/EntrepreneurshipSection";
|
||||
import ReviewSection from "./components/ReviewSection";
|
||||
import Footer from "./components/Footer";
|
||||
import { isFirebaseConfigured } from "./firebase";
|
||||
|
||||
const App = () => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
@@ -23,11 +24,34 @@ const App = () => {
|
||||
text: "",
|
||||
});
|
||||
|
||||
const formatFirebaseError = (error: unknown): string => {
|
||||
if (error && typeof error === "object") {
|
||||
const maybeCode = (error as { code?: unknown }).code;
|
||||
const maybeMessage = (error as { message?: unknown }).message;
|
||||
const code = typeof maybeCode === "string" ? maybeCode : undefined;
|
||||
const message = typeof maybeMessage === "string" ? maybeMessage : undefined;
|
||||
if (code && message) return `${code}: ${message}`;
|
||||
if (message) return message;
|
||||
}
|
||||
return String(error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchReviews().then(setReviews);
|
||||
fetchReviews()
|
||||
.then(setReviews)
|
||||
.catch((error) => {
|
||||
console.error("Error fetching reviews:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddReview = () => {
|
||||
if (!isFirebaseConfigured) {
|
||||
alert(
|
||||
"Reviews are not configured yet. Add VITE_FIREBASE_* values to a .env/.env.local file and restart `pnpm dev`."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!newReview.name ||
|
||||
newReview.rating < 1 ||
|
||||
@@ -42,14 +66,18 @@ const App = () => {
|
||||
// Add the new review to the state
|
||||
addReview(newReview)
|
||||
.then(() => {
|
||||
fetchReviews().then(setReviews);
|
||||
fetchReviews()
|
||||
.then(setReviews)
|
||||
.catch((error) => {
|
||||
console.error("Error fetching reviews after add:", error);
|
||||
});
|
||||
alert("Review added successfully!");
|
||||
setNewReview({ name: "", rating: -1, position: "", text: "" });
|
||||
setShowModal(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error adding review:", error);
|
||||
alert("Failed to add review. Please try again later.");
|
||||
alert(`Failed to add review: ${formatFirebaseError(error)}`);
|
||||
setShowModal(false);
|
||||
});
|
||||
};
|
||||
@@ -60,29 +88,26 @@ const App = () => {
|
||||
.map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${
|
||||
i < rating ? "fill-yellow-400 text-yellow-400" : "text-gray-300"
|
||||
}`}
|
||||
className={`w-4 h-4 ${i < rating ? "fill-yellow-400 text-yellow-400" : "text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-h-screen transition-all duration-300 ${
|
||||
theme === "dark"
|
||||
className={`min-h-screen transition-all duration-300 ${theme === "dark"
|
||||
? "bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"
|
||||
: "bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{/* Theme Toggle Button */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`fixed top-4 right-4 z-50 p-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-300 border ${
|
||||
theme === "dark"
|
||||
className={`fixed top-4 right-4 z-50 p-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-300 border ${theme === "dark"
|
||||
? "bg-slate-800 text-white border-slate-700 hover:bg-slate-700"
|
||||
: "bg-white text-slate-800 border-slate-200 hover:bg-slate-50"
|
||||
}`}
|
||||
}`}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === "light" ? (
|
||||
@@ -128,26 +153,23 @@ const App = () => {
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div
|
||||
className={`rounded-2xl shadow-2xl w-full max-w-md ${
|
||||
theme === "dark" ? "bg-slate-800" : "bg-white"
|
||||
}`}
|
||||
className={`rounded-2xl shadow-2xl w-full max-w-md ${theme === "dark" ? "bg-slate-800" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3
|
||||
className={`text-xl font-semibold ${
|
||||
theme === "dark" ? "text-white" : "text-slate-800"
|
||||
}`}
|
||||
className={`text-xl font-semibold ${theme === "dark" ? "text-white" : "text-slate-800"
|
||||
}`}
|
||||
>
|
||||
Add a Review
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className={`transition-colors ${
|
||||
theme === "dark"
|
||||
className={`transition-colors ${theme === "dark"
|
||||
? "text-slate-500 hover:text-slate-300"
|
||||
: "text-slate-400 hover:text-slate-600"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
@@ -155,9 +177,8 @@ const App = () => {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1 ${
|
||||
theme === "dark" ? "text-slate-300" : "text-slate-700"
|
||||
}`}
|
||||
className={`block text-sm font-medium mb-1 ${theme === "dark" ? "text-slate-300" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
@@ -167,19 +188,17 @@ const App = () => {
|
||||
onChange={(e) =>
|
||||
setNewReview({ ...newReview, name: e.target.value })
|
||||
}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 ${
|
||||
theme === "dark"
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 ${theme === "dark"
|
||||
? "border-slate-600 bg-slate-700 text-white"
|
||||
: "border-slate-300 bg-white text-slate-900"
|
||||
}`}
|
||||
}`}
|
||||
placeholder="Your full name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1 ${
|
||||
theme === "dark" ? "text-slate-300" : "text-slate-700"
|
||||
}`}
|
||||
className={`block text-sm font-medium mb-1 ${theme === "dark" ? "text-slate-300" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
Position
|
||||
</label>
|
||||
@@ -189,19 +208,17 @@ const App = () => {
|
||||
onChange={(e) =>
|
||||
setNewReview({ ...newReview, position: e.target.value })
|
||||
}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 ${
|
||||
theme === "dark"
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 ${theme === "dark"
|
||||
? "border-slate-600 bg-slate-700 text-white"
|
||||
: "border-slate-300 bg-white text-slate-900"
|
||||
}`}
|
||||
}`}
|
||||
placeholder="Your job title and company"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1 ${
|
||||
theme === "dark" ? "text-slate-300" : "text-slate-700"
|
||||
}`}
|
||||
className={`block text-sm font-medium mb-1 ${theme === "dark" ? "text-slate-300" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
Rating
|
||||
</label>
|
||||
@@ -215,13 +232,12 @@ const App = () => {
|
||||
className="text-2xl transition-transform hover:scale-110"
|
||||
>
|
||||
<Star
|
||||
className={`w-6 h-6 ${
|
||||
star <= newReview.rating
|
||||
className={`w-6 h-6 ${star <= newReview.rating
|
||||
? "fill-yellow-400 text-yellow-400"
|
||||
: theme === "dark"
|
||||
? "text-gray-600"
|
||||
: "text-gray-300"
|
||||
}`}
|
||||
? "text-gray-600"
|
||||
: "text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
@@ -229,9 +245,8 @@ const App = () => {
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1 ${
|
||||
theme === "dark" ? "text-slate-300" : "text-slate-700"
|
||||
}`}
|
||||
className={`block text-sm font-medium mb-1 ${theme === "dark" ? "text-slate-300" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
Review
|
||||
</label>
|
||||
@@ -240,11 +255,10 @@ const App = () => {
|
||||
onChange={(e) =>
|
||||
setNewReview({ ...newReview, text: e.target.value })
|
||||
}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 ${
|
||||
theme === "dark"
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 ${theme === "dark"
|
||||
? "border-slate-600 bg-slate-700 text-white"
|
||||
: "border-slate-300 bg-white text-slate-900"
|
||||
}`}
|
||||
}`}
|
||||
rows={4}
|
||||
placeholder="Share your experience working with Maaz..."
|
||||
/>
|
||||
@@ -253,17 +267,17 @@ const App = () => {
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className={`flex-1 px-4 py-2 border rounded-lg transition-colors ${
|
||||
theme === "dark"
|
||||
className={`flex-1 px-4 py-2 border rounded-lg transition-colors ${theme === "dark"
|
||||
? "border-slate-600 text-slate-300 bg-slate-700 hover:bg-slate-600"
|
||||
: "border-slate-300 text-slate-700 bg-white hover:bg-slate-50"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddReview}
|
||||
className="flex-1 px-4 py-2 text-white transition-colors bg-indigo-600 rounded-lg hover:bg-indigo-700"
|
||||
disabled={!isFirebaseConfigured}
|
||||
className="flex-1 px-4 py-2 text-white transition-colors bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600"
|
||||
>
|
||||
Add Review
|
||||
</button>
|
||||
|
||||
@@ -2,52 +2,51 @@ import { Mail, Linkedin, Github } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
isOutOfCollege?: boolean;
|
||||
isOutOfCollege?: boolean;
|
||||
}
|
||||
|
||||
const Footer: React.FC<Props> = ({ isOutOfCollege }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<section className="mb-16">
|
||||
<div className={`rounded-2xl shadow-xl text-white p-12 text-center transition-all duration-300 ${
|
||||
theme === 'dark'
|
||||
? 'bg-gradient-to-r from-indigo-700 to-purple-700'
|
||||
: 'bg-gradient-to-r from-indigo-600 to-purple-600'
|
||||
}`}>
|
||||
<h2 className="mb-6 text-4xl font-bold">Let's Connect</h2>
|
||||
<p className="mb-8 text-xl opacity-90">
|
||||
Ready to collaborate on something amazing? I'm always excited to
|
||||
discuss new opportunities and innovative projects.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
<a
|
||||
href="khokharmaaz@gmail.com"
|
||||
className="flex items-center gap-2 px-6 py-3 transition-colors rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
Email Me
|
||||
</a>
|
||||
{isOutOfCollege && (
|
||||
<a
|
||||
href="https://linkedin.com/MyLinkedinProfile"
|
||||
className="flex items-center gap-2 px-6 py-3 transition-colors rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30"
|
||||
>
|
||||
<Linkedin className="w-5 h-5" />
|
||||
LinkedIn
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/coolestcoder655"
|
||||
className="flex items-center gap-2 px-6 py-3 transition-colors rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30"
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<section className="mb-16">
|
||||
<div className={`rounded-2xl shadow-xl text-white p-12 text-center transition-all duration-300 ${theme === 'dark'
|
||||
? 'bg-linear-to-r from-indigo-700 to-purple-700'
|
||||
: 'bg-linear-to-r from-indigo-600 to-purple-600'
|
||||
}`}>
|
||||
<h2 className="mb-6 text-4xl font-bold">Let's Connect</h2>
|
||||
<p className="mb-8 text-xl opacity-90">
|
||||
Ready to collaborate on something amazing? I'm always excited to
|
||||
discuss new opportunities and innovative projects.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
<a
|
||||
href="mailto:khokharmaaz@gmail.com"
|
||||
className="flex items-center gap-2 px-6 py-3 transition-colors rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
Email Me
|
||||
</a>
|
||||
{isOutOfCollege && (
|
||||
<a
|
||||
href="https://linkedin.com/MyLinkedinProfile"
|
||||
className="flex items-center gap-2 px-6 py-3 transition-colors rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30"
|
||||
>
|
||||
<Linkedin className="w-5 h-5" />
|
||||
LinkedIn
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/coolestcoder655"
|
||||
className="flex items-center gap-2 px-6 py-3 transition-colors rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30"
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -1,88 +1,79 @@
|
||||
import { Star, Plus } from 'lucide-react';
|
||||
import { type JSX } from 'react';
|
||||
import type { FC, JSX } from 'react';
|
||||
import { type Review } from '../reviewsApi';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
setShowModal: (show: boolean) => void;
|
||||
reviews: Review[];
|
||||
renderStars: (rating: number) => JSX.Element[];
|
||||
setShowModal: (show: boolean) => void;
|
||||
reviews: Review[];
|
||||
renderStars: (rating: number) => JSX.Element[];
|
||||
}
|
||||
|
||||
const ReviewSection: React.FC<Props> = ({ setShowModal, renderStars, reviews }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<section className="mb-16">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Star className={`w-8 h-8 transition-colors duration-300 ${
|
||||
theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'
|
||||
}`} />
|
||||
<h2 className={`text-3xl font-bold transition-colors duration-300 ${
|
||||
theme === 'dark' ? 'text-white' : 'text-slate-800'
|
||||
}`}>
|
||||
Professional Reviews
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-6 py-3 font-medium text-white transition-colors bg-indigo-600 rounded-lg hover:bg-indigo-700"
|
||||
const ReviewSection: FC<Props> = ({ setShowModal, renderStars, reviews }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<section className="mb-16">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Star className={`w-8 h-8 transition-colors duration-300 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'
|
||||
}`} />
|
||||
<h2 className={`text-3xl font-bold transition-colors duration-300 ${theme === 'dark' ? 'text-white' : 'text-slate-800'
|
||||
}`}>
|
||||
Professional Reviews
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-6 py-3 font-medium text-white transition-colors bg-indigo-600 rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Review
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{reviews.length > 0 ? (
|
||||
reviews.map((review, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`rounded-2xl shadow-lg p-6 hover:shadow-xl transition-all duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Review
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{reviews.length > 1 ? (
|
||||
reviews.map((review, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`rounded-2xl shadow-lg p-6 hover:shadow-xl transition-all duration-300 ${
|
||||
theme === 'dark' ? 'bg-slate-800' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors duration-300 ${
|
||||
theme === 'dark' ? 'bg-blue-900/30' : 'bg-blue-100'
|
||||
}`}>
|
||||
<Star className={`w-8 h-8 ${
|
||||
theme === 'dark' ? 'text-blue-400' : 'text-blue-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-lg font-semibold transition-colors duration-300 ${
|
||||
theme === 'dark' ? 'text-white' : 'text-slate-800'
|
||||
}`}>
|
||||
{review.name}
|
||||
</h3>
|
||||
<p className={`text-sm transition-colors duration-300 ${
|
||||
theme === 'dark' ? 'text-slate-400' : 'text-slate-600'
|
||||
}`}>
|
||||
{review.position}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mb-4">
|
||||
{renderStars(review.rating)}
|
||||
</div>
|
||||
<p className={`transition-colors duration-300 ${
|
||||
theme === 'dark' ? 'text-slate-300' : 'text-slate-700'
|
||||
}`}>{review.text}</p>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors duration-300 ${theme === 'dark' ? 'bg-blue-900/30' : 'bg-blue-100'
|
||||
}`}>
|
||||
<Star className={`w-8 h-8 ${theme === 'dark' ? 'text-blue-400' : 'text-blue-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-lg font-semibold transition-colors duration-300 ${theme === 'dark' ? 'text-white' : 'text-slate-800'
|
||||
}`}>
|
||||
{review.name}
|
||||
</h3>
|
||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-600'
|
||||
}`}>
|
||||
{review.position}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={`col-span-full text-center rounded-2xl p-6 transition-colors duration-300 ${
|
||||
theme === 'dark'
|
||||
? 'text-slate-400 bg-slate-700'
|
||||
: 'text-slate-500 bg-slate-100'
|
||||
}`}>
|
||||
No reviews yet. Be the first to add one!
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center mb-4">
|
||||
{renderStars(review.rating)}
|
||||
</div>
|
||||
<p className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'
|
||||
}`}>{review.text}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={`col-span-full text-center rounded-2xl p-6 transition-colors duration-300 ${theme === 'dark'
|
||||
? 'text-slate-400 bg-slate-700'
|
||||
: 'text-slate-500 bg-slate-100'
|
||||
}`}>
|
||||
No reviews yet. Be the first to add one!
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewSection;
|
||||
58
src/firebase.ts
Normal file
58
src/firebase.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { initializeApp, type FirebaseApp } from "firebase/app";
|
||||
import { getFirestore, type Firestore } from "firebase/firestore";
|
||||
|
||||
const requiredEnvKeys = [
|
||||
"VITE_FIREBASE_API_KEY",
|
||||
"VITE_FIREBASE_AUTH_DOMAIN",
|
||||
"VITE_FIREBASE_PROJECT_ID",
|
||||
"VITE_FIREBASE_STORAGE_BUCKET",
|
||||
"VITE_FIREBASE_MESSAGING_SENDER_ID",
|
||||
"VITE_FIREBASE_APP_ID",
|
||||
] as const;
|
||||
|
||||
type RequiredEnvKey = (typeof requiredEnvKeys)[number];
|
||||
|
||||
type FirebaseConfig = {
|
||||
apiKey: string;
|
||||
authDomain: string;
|
||||
projectId: string;
|
||||
storageBucket: string;
|
||||
messagingSenderId: string;
|
||||
appId: string;
|
||||
measurementId?: string;
|
||||
};
|
||||
|
||||
function buildFirebaseConfig(): FirebaseConfig | null {
|
||||
const missing = requiredEnvKeys.filter(
|
||||
(key) => !import.meta.env[key as RequiredEnvKey]
|
||||
);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`[firebase] Missing Vite env vars: ${missing.join(
|
||||
", "
|
||||
)}. Add them to .env (dev) or as build args (docker build).`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY!,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN!,
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID!,
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET!,
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID!,
|
||||
appId: import.meta.env.VITE_FIREBASE_APP_ID!,
|
||||
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID,
|
||||
};
|
||||
}
|
||||
|
||||
const firebaseConfig = buildFirebaseConfig();
|
||||
|
||||
export const isFirebaseConfigured = Boolean(firebaseConfig);
|
||||
|
||||
export const app: FirebaseApp | null = firebaseConfig
|
||||
? initializeApp(firebaseConfig)
|
||||
: null;
|
||||
|
||||
export const db: Firestore | null = app ? getFirestore(app) : null;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db } from '../firebase';
|
||||
import { db } from './firebase.ts';
|
||||
import { collection, getDocs, addDoc } from 'firebase/firestore';
|
||||
|
||||
export type Review = {
|
||||
@@ -8,13 +8,15 @@ export type Review = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const reviewsCollection = collection(db, 'reviews');
|
||||
const reviewsCollection = db ? collection(db, 'reviews') : null;
|
||||
|
||||
export async function fetchReviews(): Promise<Review[]> {
|
||||
if (!reviewsCollection) return [];
|
||||
const snapshot = await getDocs(reviewsCollection);
|
||||
return snapshot.docs.map(doc => doc.data() as Review);
|
||||
}
|
||||
|
||||
export async function addReview(review: Review): Promise<void> {
|
||||
if (!reviewsCollection) throw new Error('Firestore is not configured (missing Vite Firebase env vars).');
|
||||
await addDoc(reviewsCollection, review);
|
||||
}
|
||||
|
||||
14
src/vite-env.d.ts
vendored
14
src/vite-env.d.ts
vendored
@@ -1 +1,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_FIREBASE_API_KEY?: string;
|
||||
readonly VITE_FIREBASE_AUTH_DOMAIN?: string;
|
||||
readonly VITE_FIREBASE_PROJECT_ID?: string;
|
||||
readonly VITE_FIREBASE_STORAGE_BUCKET?: string;
|
||||
readonly VITE_FIREBASE_MESSAGING_SENDER_ID?: string;
|
||||
readonly VITE_FIREBASE_APP_ID?: string;
|
||||
readonly VITE_FIREBASE_MEASUREMENT_ID?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user