diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7c6a1bc --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 8a68186..1b8cf72 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,4 @@ pnpm-lock.yaml pnpm-workspace.yaml # Environment variables -.env -.env* \ No newline at end of file +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 749b3c4..3c13128 100644 --- a/Dockerfile +++ b/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 diff --git a/docker-compose.yml b/docker-compose.yml index 2879854..d99c84a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/firebase.ts b/firebase.ts index 6e24b50..65f44b4 100644 --- a/firebase.ts +++ b/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 }; \ No newline at end of file +export { app, db } from "./src/firebase.ts"; \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index c6f738d..82bacfd 100644 --- a/src/App.tsx +++ b/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) => ( )); }; return (
{/* Theme Toggle Button */} @@ -155,9 +177,8 @@ const App = () => {
@@ -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" />
@@ -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" />
@@ -215,13 +232,12 @@ const App = () => { className="text-2xl transition-transform hover:scale-110" > ))} @@ -229,9 +245,8 @@ const App = () => {
@@ -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 = () => {
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index dec5b8a..813cd9d 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -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 = ({ isOutOfCollege }) => { - const { theme } = useTheme(); - - return ( -
-
-

Let's Connect

-

- Ready to collaborate on something amazing? I'm always excited to - discuss new opportunities and innovative projects. -

-
- - - Email Me - - {isOutOfCollege && ( - - - LinkedIn - - )} - - - GitHub - -
-
-
- ); + const { theme } = useTheme(); + + return ( +
+
+

Let's Connect

+

+ Ready to collaborate on something amazing? I'm always excited to + discuss new opportunities and innovative projects. +

+
+ + + Email Me + + {isOutOfCollege && ( + + + LinkedIn + + )} + + + GitHub + +
+
+
+ ); }; export default Footer; \ No newline at end of file diff --git a/src/components/ReviewSection.tsx b/src/components/ReviewSection.tsx index 9cc2a6d..e4f7577 100644 --- a/src/components/ReviewSection.tsx +++ b/src/components/ReviewSection.tsx @@ -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 = ({ setShowModal, renderStars, reviews }) => { - const { theme } = useTheme(); - - return ( -
-
-
- -

- Professional Reviews -

-
- +
+
+ {reviews.length > 0 ? ( + reviews.map((review, index) => ( +
- - Add Review - -
-
- {reviews.length > 1 ? ( - reviews.map((review, index) => ( -
-
-
- -
-
-

- {review.name} -

-

- {review.position} -

-
-
-
- {renderStars(review.rating)} -
-

{review.text}

+
+
+ +
+
+

+ {review.name} +

+

+ {review.position} +

- )) - ) : ( -
- No reviews yet. Be the first to add one!
- )} +
+ {renderStars(review.rating)} +
+

{review.text}

+
+ )) + ) : ( +
+ No reviews yet. Be the first to add one!
-
- ); + )} +
+ + ); }; export default ReviewSection; \ No newline at end of file diff --git a/src/firebase.ts b/src/firebase.ts new file mode 100644 index 0000000..0bdb870 --- /dev/null +++ b/src/firebase.ts @@ -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; diff --git a/src/reviewsApi.ts b/src/reviewsApi.ts index 597b992..9d64604 100644 --- a/src/reviewsApi.ts +++ b/src/reviewsApi.ts @@ -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 { + if (!reviewsCollection) return []; const snapshot = await getDocs(reviewsCollection); return snapshot.docs.map(doc => doc.data() as Review); } export async function addReview(review: Review): Promise { + if (!reviewsCollection) throw new Error('Firestore is not configured (missing Vite Firebase env vars).'); await addDoc(reviewsCollection, review); } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..f088aa2 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,15 @@ /// + +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; +}