diff --git a/README.md b/README.md index 907f3e6..85c2b56 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Welcome to **React Kolkata**! This project provides a robust foundation for Next - **Docker Support**: Easy containerization and deployment - **Internationalization (i18n)**: Built-in multi-language support with next-intl - **Playwright & Vitest**: E2E, unit, and integration testing +- **Newsletter Subscription**: Engage community members with updates and announcements - **Community Events**: [React Kolkata on lu.ma](https://lu.ma/reactkolkata) --- diff --git a/src/app/[locale]/playground/page.tsx b/src/app/[locale]/playground/page.tsx new file mode 100644 index 0000000..588231a --- /dev/null +++ b/src/app/[locale]/playground/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { CodeEditor } from "@/components/custom/code-editor"; +import { LivePreview } from "@/components/custom/live-preview"; +import { PlaygroundControls } from "@/components/custom/playground-controls"; +import { DEFAULT_REACT_CODE } from "@/types/playground"; + +export default function PlaygroundPage() { + const t = useTranslations("Playground"); + const [code, setCode] = useState(DEFAULT_REACT_CODE); + const [previewCode, setPreviewCode] = useState(DEFAULT_REACT_CODE); + const [isSaved, setIsSaved] = useState(false); + const [isBookmarked, setIsBookmarked] = useState(false); + const [error, setError] = useState(); + + const handleRun = () => { + setError(undefined); + setPreviewCode(code); + }; + + const handleSave = () => { + // TODO: Implement actual save to backend/localStorage + console.log("Saving code:", code); + setIsSaved(true); + setTimeout(() => setIsSaved(false), 2000); + }; + + const handleReset = () => { + setCode(DEFAULT_REACT_CODE); + setPreviewCode(DEFAULT_REACT_CODE); + setError(undefined); + }; + + const handleShare = () => { + // TODO: Implement actual sharing logic + console.log("Sharing code:", code); + }; + + const handleToggleBookmark = () => { + setIsBookmarked(!isBookmarked); + }; + + const handleCodeChange = (newCode: string) => { + setCode(newCode); + setIsSaved(false); + }; + + return ( +
+ {/* Header */} +
+
+

{t("title")}

+

{t("description")}

+
+
+ + {/* Controls */} + + + {/* Editor and Preview */} +
+
+ {/* Code Editor */} +
+ +
+ + {/* Live Preview */} +
+ +
+
+
+ + {/* Info Section */} +
+
+
+

{t("tips.keyboard.title")}

+
    +
  • • Tab - {t("tips.keyboard.tab")}
  • +
  • • Ctrl+S - {t("tips.keyboard.save")}
  • +
  • • Ctrl+Enter - {t("tips.keyboard.run")}
  • +
+
+
+

{t("tips.features.title")}

+
    +
  • • {t("tips.features.autoRun")}
  • +
  • • {t("tips.features.share")}
  • +
  • • {t("tips.features.bookmark")}
  • +
+
+
+

{t("tips.community.title")}

+

+ {t("tips.community.description")} +

+
+
+
+
+ ); +} diff --git a/src/base/data/dummy/index.ts b/src/base/data/dummy/index.ts index c2eb8d8..b99fa37 100644 --- a/src/base/data/dummy/index.ts +++ b/src/base/data/dummy/index.ts @@ -1,5 +1,7 @@ import { EVENT_TYPES } from "@/types/event"; +export { snippets } from "./snippets"; + export const events = [ { id: "rk-nov-2025", diff --git a/src/base/data/dummy/snippets.ts b/src/base/data/dummy/snippets.ts new file mode 100644 index 0000000..fbab0f0 --- /dev/null +++ b/src/base/data/dummy/snippets.ts @@ -0,0 +1,425 @@ +import { CodeSnippet } from "@/types/playground"; + +export const snippets: CodeSnippet[] = [ + { + id: "1", + title: "Custom useDebounce Hook", + description: "A powerful debounce hook to optimize performance by delaying function execution", + code: `import { useEffect, useState } from 'react'; + +function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +function App() { + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + useEffect(() => { + if (debouncedSearchTerm) { + console.log('Searching for:', debouncedSearchTerm); + } + }, [debouncedSearchTerm]); + + return ( +
+

Search with Debounce

+ setSearchTerm(e.target.value)} + placeholder="Type to search..." + style={{ padding: '10px', width: '300px', fontSize: '16px' }} + /> +

Debounced value: {debouncedSearchTerm}

+
+ ); +} + +export default App;`, + language: "jsx", + author: { + name: "Priya Sharma", + id: "user1", + }, + tags: ["hooks", "performance", "utils"], + createdAt: "2026-01-28T10:00:00Z", + updatedAt: "2026-01-28T10:00:00Z", + isPublic: true, + likes: 45, + views: 320, + bookmarks: 28, + featured: true, + }, + { + id: "2", + title: "Dark Mode Toggle", + description: "Simple dark mode implementation using Context API and local storage", + code: `import React, { createContext, useContext, useState, useEffect } from 'react'; + +const ThemeContext = createContext(); + +export const useTheme = () => useContext(ThemeContext); + +function ThemeProvider({ children }) { + const [isDark, setIsDark] = useState(false); + + useEffect(() => { + const saved = localStorage.getItem('theme'); + if (saved) setIsDark(saved === 'dark'); + }, []); + + const toggleTheme = () => { + setIsDark(prev => { + const newTheme = !prev; + localStorage.setItem('theme', newTheme ? 'dark' : 'light'); + return newTheme; + }); + }; + + return ( + + {children} + + ); +} + +function App() { + const { isDark, toggleTheme } = useTheme(); + + return ( +
+

Dark Mode Demo

+ +
+ ); +} + +export default () => ( + + + +);`, + language: "jsx", + author: { + name: "Rahul Das", + id: "user2", + }, + tags: ["state-management", "hooks", "components"], + createdAt: "2026-01-27T14:30:00Z", + updatedAt: "2026-01-27T14:30:00Z", + isPublic: true, + likes: 62, + views: 450, + bookmarks: 41, + featured: true, + }, + { + id: "3", + title: "Infinite Scroll List", + description: "Implement infinite scrolling with intersection observer", + code: `import { useState, useEffect, useRef } from 'react'; + +function App() { + const [items, setItems] = useState(Array.from({ length: 20 }, (_, i) => i + 1)); + const [loading, setLoading] = useState(false); + const loaderRef = useRef(null); + + const loadMore = () => { + setLoading(true); + setTimeout(() => { + setItems(prev => [ + ...prev, + ...Array.from({ length: 20 }, (_, i) => prev.length + i + 1) + ]); + setLoading(false); + }, 1000); + }; + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !loading) { + loadMore(); + } + }, + { threshold: 1.0 } + ); + + if (loaderRef.current) { + observer.observe(loaderRef.current); + } + + return () => observer.disconnect(); + }, [loading]); + + return ( +
+

Infinite Scroll Demo

+
+ {items.map(item => ( +
+ Item #{item} +
+ ))} +
+
+ {loading &&

Loading more...

} +
+
+ ); +} + +export default App;`, + language: "jsx", + author: { + name: "Ananya Roy", + id: "user3", + }, + tags: ["performance", "hooks", "api"], + createdAt: "2026-01-26T09:15:00Z", + updatedAt: "2026-01-26T09:15:00Z", + isPublic: true, + likes: 38, + views: 275, + bookmarks: 22, + }, + { + id: "4", + title: "Animated Counter", + description: "Smooth counting animation with easing function", + code: `import { useState, useEffect, useRef } from 'react'; + +function useCountUp(end, duration = 2000) { + const [count, setCount] = useState(0); + const countRef = useRef(0); + + useEffect(() => { + const startTime = Date.now(); + const endTime = startTime + duration; + + const easeOutQuad = (t) => t * (2 - t); + + const updateCount = () => { + const now = Date.now(); + const progress = Math.min((now - startTime) / duration, 1); + const easedProgress = easeOutQuad(progress); + + countRef.current = Math.floor(easedProgress * end); + setCount(countRef.current); + + if (now < endTime) { + requestAnimationFrame(updateCount); + } + }; + + requestAnimationFrame(updateCount); + }, [end, duration]); + + return count; +} + +function App() { + const [target, setTarget] = useState(100); + const count = useCountUp(target); + + return ( +
+

+ {count} +

+ +
+ ); +} + +export default App;`, + language: "jsx", + author: { + name: "Vikram Mehta", + id: "user4", + }, + tags: ["animations", "hooks", "utils"], + createdAt: "2026-01-25T16:45:00Z", + updatedAt: "2026-01-25T16:45:00Z", + isPublic: true, + likes: 29, + views: 198, + bookmarks: 15, + }, + { + id: "5", + title: "Form Validation Hook", + description: "Reusable form validation with custom rules", + code: `import { useState } from 'react'; + +function useFormValidation(initialValues, validationRules) { + const [values, setValues] = useState(initialValues); + const [errors, setErrors] = useState({}); + + const validate = (name, value) => { + const rules = validationRules[name]; + if (!rules) return ''; + + for (const rule of rules) { + const error = rule(value, values); + if (error) return error; + } + return ''; + }; + + const handleChange = (e) => { + const { name, value } = e.target; + setValues(prev => ({ ...prev, [name]: value })); + const error = validate(name, value); + setErrors(prev => ({ ...prev, [name]: error })); + }; + + const handleSubmit = (callback) => (e) => { + e.preventDefault(); + const newErrors = {}; + Object.keys(validationRules).forEach(name => { + const error = validate(name, values[name]); + if (error) newErrors[name] = error; + }); + setErrors(newErrors); + if (Object.keys(newErrors).length === 0) { + callback(values); + } + }; + + return { values, errors, handleChange, handleSubmit }; +} + +function App() { + const { values, errors, handleChange, handleSubmit } = useFormValidation( + { email: '', password: '' }, + { + email: [ + (value) => !value && 'Email is required', + (value) => !/\\S+@\\S+\\.\\S+/.test(value) && 'Email is invalid' + ], + password: [ + (value) => !value && 'Password is required', + (value) => value.length < 6 && 'Password must be at least 6 characters' + ] + } + ); + + const onSubmit = (data) => { + alert('Form submitted: ' + JSON.stringify(data)); + }; + + return ( +
+

Login Form

+
+
+ + {errors.email &&

{errors.email}

} +
+
+ + {errors.password &&

{errors.password}

} +
+ +
+
+ ); +} + +export default App;`, + language: "jsx", + author: { + name: "Sneha Gupta", + id: "user5", + }, + tags: ["forms", "hooks", "utils"], + createdAt: "2026-01-24T11:20:00Z", + updatedAt: "2026-01-24T11:20:00Z", + isPublic: true, + likes: 51, + views: 389, + bookmarks: 35, + }, +]; diff --git a/src/components/common/navbar/index.tsx b/src/components/common/navbar/index.tsx index b07579c..0b15902 100644 --- a/src/components/common/navbar/index.tsx +++ b/src/components/common/navbar/index.tsx @@ -29,6 +29,7 @@ const Navbar = () => { { href: "/#core-team", label: t("core_team"), external: false, isHashLink: true }, { href: "/contributors", label: t("contributors"), external: false, isHashLink: false }, { href: "/events", label: t("events"), external: false, isHashLink: false }, + { href: "/playground", label: t("playground"), external: false, isHashLink: false }, ]; const handleJoinClick = () => { diff --git a/src/components/common/newsletter/NewsletterSubscription.tsx b/src/components/common/newsletter/NewsletterSubscription.tsx new file mode 100644 index 0000000..cb980b7 --- /dev/null +++ b/src/components/common/newsletter/NewsletterSubscription.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Mail, CheckCircle2, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + NewsletterFormData, + NEWSLETTER_INTERESTS, + NewsletterInterest, +} from "@/types/newsletter"; + +export default function NewsletterSubscription() { + const t = useTranslations("Newsletter"); + const [email, setEmail] = useState(""); + const [selectedInterests, setSelectedInterests] = useState([]); + const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [errorMessage, setErrorMessage] = useState(""); + + const interests = [ + { value: NEWSLETTER_INTERESTS.EVENTS, label: t("interests.events") }, + { value: NEWSLETTER_INTERESTS.TECH_TALKS, label: t("interests.techTalks") }, + { value: NEWSLETTER_INTERESTS.WORKSHOPS, label: t("interests.workshops") }, + { value: NEWSLETTER_INTERESTS.COMMUNITY_NEWS, label: t("interests.communityNews") }, + ]; + + const toggleInterest = (interest: NewsletterInterest) => { + setSelectedInterests((prev) => + prev.includes(interest) + ? prev.filter((i) => i !== interest) + : [...prev, interest] + ); + }; + + const validateEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateEmail(email)) { + setStatus("error"); + setErrorMessage(t("errors.invalidEmail")); + return; + } + + setStatus("loading"); + setErrorMessage(""); + + try { + // TODO: Replace with actual API call + // const response = await fetch('/api/newsletter/subscribe', { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ email, interests: selectedInterests }), + // }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 1500)); + + setStatus("success"); + setEmail(""); + setSelectedInterests([]); + + // Reset success message after 5 seconds + setTimeout(() => setStatus("idle"), 5000); + } catch (error) { + setStatus("error"); + setErrorMessage(t("errors.generic")); + } + }; + + return ( +
+
+
+ +
+

{t("title")}

+

{t("description")}

+
+ + {status === "success" ? ( +
+ +

+ {t("success.title")} +

+

{t("success.message")}

+
+ ) : ( +
+
+ + setEmail(e.target.value)} + placeholder={t("emailPlaceholder")} + className="w-full px-4 py-3 border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-background" + required + disabled={status === "loading"} + /> +
+ +
+ +
+ {interests.map((interest) => ( + toggleInterest(interest.value)} + > + {interest.label} + + ))} +
+
+ + {status === "error" && ( +
+ +

{errorMessage}

+
+ )} + + + +

+ {t("privacyNote")} +

+
+ )} +
+ ); +} diff --git a/src/components/common/newsletter/__tests__/NewsletterSubscription.test.tsx b/src/components/common/newsletter/__tests__/NewsletterSubscription.test.tsx new file mode 100644 index 0000000..18db15d --- /dev/null +++ b/src/components/common/newsletter/__tests__/NewsletterSubscription.test.tsx @@ -0,0 +1,152 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextIntlClientProvider } from "next-intl"; +import NewsletterSubscription from "../NewsletterSubscription"; + +const messages = { + Newsletter: { + title: "Stay Updated", + description: "Subscribe to our newsletter to get the latest updates about events, workshops, and community news.", + emailLabel: "Email Address", + emailPlaceholder: "your.email@example.com", + interestsLabel: "What are you interested in? (Optional)", + interests: { + events: "Upcoming Events", + techTalks: "Tech Talks & Presentations", + workshops: "Workshops & Hands-on Sessions", + communityNews: "Community Updates & News", + }, + button: { + subscribe: "Subscribe to Newsletter", + loading: "Subscribing...", + }, + success: { + title: "Successfully Subscribed!", + message: "Thank you for subscribing. You'll receive our latest updates in your inbox.", + }, + errors: { + invalidEmail: "Please enter a valid email address.", + generic: "Something went wrong. Please try again later.", + }, + privacyNote: "We respect your privacy. Unsubscribe anytime.", + }, +}; + +const renderComponent = () => { + return render( + + + + ); +}; + +describe("NewsletterSubscription", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the newsletter form", () => { + renderComponent(); + + expect(screen.getByText("Stay Updated")).toBeInTheDocument(); + expect(screen.getByText("Subscribe to our newsletter to get the latest updates about events, workshops, and community news.")).toBeInTheDocument(); + expect(screen.getByLabelText("Email Address")).toBeInTheDocument(); + expect(screen.getByText("Subscribe to Newsletter")).toBeInTheDocument(); + }); + + it("renders all interest badges", () => { + renderComponent(); + + expect(screen.getByText("Upcoming Events")).toBeInTheDocument(); + expect(screen.getByText("Tech Talks & Presentations")).toBeInTheDocument(); + expect(screen.getByText("Workshops & Hands-on Sessions")).toBeInTheDocument(); + expect(screen.getByText("Community Updates & News")).toBeInTheDocument(); + }); + + it("allows selecting and deselecting interests", () => { + renderComponent(); + + const eventsBadge = screen.getByText("Upcoming Events"); + + // Click to select + fireEvent.click(eventsBadge); + expect(eventsBadge.parentElement).toHaveClass("bg-primary"); + + // Click again to deselect + fireEvent.click(eventsBadge); + expect(eventsBadge.parentElement).not.toHaveClass("bg-primary"); + }); + + it("shows error for invalid email", async () => { + renderComponent(); + + const emailInput = screen.getByLabelText("Email Address"); + const submitButton = screen.getByText("Subscribe to Newsletter"); + + // Enter invalid email + fireEvent.change(emailInput, { target: { value: "invalid-email" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText("Please enter a valid email address.")).toBeInTheDocument(); + }); + }); + + it("handles successful subscription", async () => { + renderComponent(); + + const emailInput = screen.getByLabelText("Email Address"); + const submitButton = screen.getByText("Subscribe to Newsletter"); + + // Enter valid email + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.click(submitButton); + + // Wait for loading state + await waitFor(() => { + expect(screen.getByText("Subscribing...")).toBeInTheDocument(); + }); + + // Wait for success message + await waitFor(() => { + expect(screen.getByText("Successfully Subscribed!")).toBeInTheDocument(); + expect(screen.getByText("Thank you for subscribing. You'll receive our latest updates in your inbox.")).toBeInTheDocument(); + }, { timeout: 2000 }); + }); + + it("clears form after successful submission", async () => { + renderComponent(); + + const emailInput = screen.getByLabelText("Email Address") as HTMLInputElement; + const submitButton = screen.getByText("Subscribe to Newsletter"); + const eventsBadge = screen.getByText("Upcoming Events"); + + // Fill form + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.click(eventsBadge); + fireEvent.click(submitButton); + + // Wait for success + await waitFor(() => { + expect(screen.getByText("Successfully Subscribed!")).toBeInTheDocument(); + }, { timeout: 2000 }); + + // Form should be cleared (but hidden behind success message) + expect(emailInput.value).toBe(""); + }); + + it("disables form during submission", async () => { + renderComponent(); + + const emailInput = screen.getByLabelText("Email Address"); + const submitButton = screen.getByText("Subscribe to Newsletter"); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(emailInput).toBeDisabled(); + expect(submitButton).toBeDisabled(); + }); + }); +}); diff --git a/src/components/common/newsletter/index.ts b/src/components/common/newsletter/index.ts new file mode 100644 index 0000000..8713263 --- /dev/null +++ b/src/components/common/newsletter/index.ts @@ -0,0 +1 @@ +export { default as NewsletterSubscription } from "./NewsletterSubscription"; diff --git a/src/components/custom/code-editor/CodeEditor.tsx b/src/components/custom/code-editor/CodeEditor.tsx new file mode 100644 index 0000000..5306e17 --- /dev/null +++ b/src/components/custom/code-editor/CodeEditor.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { PlaygroundState } from "@/types/playground"; + +interface CodeEditorProps { + initialCode?: string; + language?: "javascript" | "typescript" | "jsx" | "tsx"; + onChange?: (code: string) => void; + readOnly?: boolean; +} + +export default function CodeEditor({ + initialCode = "", + language = "jsx", + onChange, + readOnly = false, +}: CodeEditorProps) { + const [code, setCode] = useState(initialCode); + const [lineNumbers, setLineNumbers] = useState([]); + + useEffect(() => { + const lines = code.split("\n").length; + setLineNumbers(Array.from({ length: lines }, (_, i) => i + 1)); + }, [code]); + + const handleCodeChange = (e: React.ChangeEvent) => { + const newCode = e.target.value; + setCode(newCode); + onChange?.(newCode); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Tab") { + e.preventDefault(); + const textarea = e.currentTarget; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const newCode = code.substring(0, start) + " " + code.substring(end); + setCode(newCode); + onChange?.(newCode); + + // Set cursor position after tab + setTimeout(() => { + textarea.selectionStart = textarea.selectionEnd = start + 2; + }, 0); + } + }; + + return ( +
+ {/* Editor Header */} +
+
+
+
+
+
+ {language} +
+ + {/* Code Editor */} +
+ {/* Line Numbers */} +
+ {lineNumbers.map((num) => ( +
+ {num} +
+ ))} +
+ + {/* Code Area */} +