diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5db4755..553b62b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,6 +28,8 @@ jobs: - run: npm ci - run: npm run build + env: + VITE_GEMINI_API_KEY: ${{ secrets.VITE_GEMINI_API_KEY }} - uses: actions/configure-pages@v5 diff --git a/.gitignore b/.gitignore index a547bf3..d3431e8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? +.env +.env.local +.env.*.local diff --git a/index.html b/index.html index a3b00c0..d702caf 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - devs-sesa-beginner-hackathon-2026 + SSTRUK
diff --git a/package-lock.json b/package-lock.json index db0e2ca..4295c17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "devs-sesa-beginner-hackathon-2026", "version": "0.0.0", "dependencies": { + "framer-motion": "^12.38.0", "lucide-react": "^1.8.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -270,24 +271,26 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -814,6 +817,29 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", @@ -1815,6 +1841,33 @@ "dev": true, "license": "ISC" }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2367,6 +2420,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index ac3c4e2..ff566d7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "framer-motion": "^12.38.0", "lucide-react": "^1.8.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/public/alien_crystal.png b/public/alien_crystal.png new file mode 100644 index 0000000..68e4368 Binary files /dev/null and b/public/alien_crystal.png differ diff --git a/public/alien_cyra.jpeg b/public/alien_cyra.jpeg new file mode 100644 index 0000000..c7b18a9 Binary files /dev/null and b/public/alien_cyra.jpeg differ diff --git a/public/alien_fungal.png b/public/alien_fungal.png new file mode 100644 index 0000000..b810fe5 Binary files /dev/null and b/public/alien_fungal.png differ diff --git a/public/alien_ignis.jpeg b/public/alien_ignis.jpeg new file mode 100644 index 0000000..95fe64f Binary files /dev/null and b/public/alien_ignis.jpeg differ diff --git a/public/alien_kaelen.jpeg b/public/alien_kaelen.jpeg new file mode 100644 index 0000000..42dd86d Binary files /dev/null and b/public/alien_kaelen.jpeg differ diff --git a/public/alien_lumina.webp b/public/alien_lumina.webp new file mode 100644 index 0000000..b5b47c9 Binary files /dev/null and b/public/alien_lumina.webp differ diff --git a/public/alien_plasma.png b/public/alien_plasma.png new file mode 100644 index 0000000..fb82520 Binary files /dev/null and b/public/alien_plasma.png differ diff --git a/public/alien_sparky.jpeg b/public/alien_sparky.jpeg new file mode 100644 index 0000000..4ec3b33 Binary files /dev/null and b/public/alien_sparky.jpeg differ diff --git a/public/alien_squish.webp b/public/alien_squish.webp new file mode 100644 index 0000000..878b1e4 Binary files /dev/null and b/public/alien_squish.webp differ diff --git a/public/alien_xeno.webp b/public/alien_xeno.webp new file mode 100644 index 0000000..d8bf972 Binary files /dev/null and b/public/alien_xeno.webp differ diff --git a/public/alien_zarok.webp b/public/alien_zarok.webp new file mode 100644 index 0000000..0e8ed16 Binary files /dev/null and b/public/alien_zarok.webp differ diff --git a/src/App.tsx b/src/App.tsx index 3a2fb6d..c9dbfb8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,27 +4,25 @@ import Preferences from './pages/Preferences'; import Home from './pages/home/Home'; import Explore from './pages/Explore'; import Chat from './pages/Chat'; -import { Rocket } from 'lucide-react'; +import { Starfield } from './components/Starfield'; +import { RocketTransition } from './components/RocketTransition'; + function App() { const location = useLocation(); - const hideExploreLink = location.pathname === '/' || location.pathname === '/preferences'; const isHomePage = location.pathname === '/'; return (
- - + + {!isHomePage && ( +
+ + SSTRUK Logo + +
+ )} +
} /> @@ -34,6 +32,8 @@ function App() { } />
+ +
); } diff --git a/src/components/MatchOverlay.tsx b/src/components/MatchOverlay.tsx index 11451dc..03957df 100644 --- a/src/components/MatchOverlay.tsx +++ b/src/components/MatchOverlay.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import type { AlienProfile } from '../data/mockAliens'; -import { useNavigate } from 'react-router'; import { Heart } from 'lucide-react'; +import { useRocketNav } from '../context/TransitionContext'; interface MatchOverlayProps { alien: AlienProfile; @@ -9,15 +9,15 @@ interface MatchOverlayProps { } export default function MatchOverlay({ alien, userName }: MatchOverlayProps) { - const navigate = useNavigate(); + const triggerRocketNav = useRocketNav(); useEffect(() => { - // Automatically redirect to chat after 3 seconds + // Trigger rocket navigation after 2 seconds (giving time to see the match) const timer = setTimeout(() => { - navigate(`/chat/${alien.id}`); - }, 3000); + triggerRocketNav(`/chat/${alien.id}`); + }, 2000); return () => clearTimeout(timer); - }, [navigate, alien.id]); + }, [triggerRocketNav, alien.id]); return (
-

- IT'S A MATCH! +

+ YOU'VE BEEN STARSTRUCK!

diff --git a/src/components/OrbitSystem.tsx b/src/components/OrbitSystem.tsx index 71c1c0d..6a43611 100644 --- a/src/components/OrbitSystem.tsx +++ b/src/components/OrbitSystem.tsx @@ -1,32 +1,148 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; +import { Heart } from 'lucide-react'; import { mockAliens } from '../data/mockAliens'; import type { AlienProfile } from '../data/mockAliens'; import { useAppContext } from '../context/AppContext'; import ProfileModal from './ProfileModal'; -import MatchOverlay from './MatchOverlay'; +import { getCompatibility } from '../utils/compatibility'; +import { useRocketNav } from '../context/TransitionContext'; +import ScientificWarningModal from './ScientificWarningModal'; +import { getScientificWarnings } from '../utils/scienceWarnings'; export default function OrbitSystem() { - const { preferences, addMatch } = useAppContext(); + + const { preferences, addMatch, matches } = useAppContext(); + const triggerRocketNav = useRocketNav(); const [selectedAlien, setSelectedAlien] = useState(null); - const [matchedAlien, setMatchedAlien] = useState(null); + const [dismissedIds, setDismissedIds] = useState>(new Set()); + const [animStage, setAnimStage] = useState<'none' | 'heart' | 'break' | 'final'>('none'); + const [pendingMatchAlien, setPendingMatchAlien] = useState(null); + + // Keep exactly 5 slots for the 5 orbit tracks + const [activeIds, setActiveIds] = useState<(string | null)[]>([null, null, null, null, null]); + + useEffect(() => { + if (!preferences) return; + + // Available aliens are those within distance, not matched, not dismissed, and not currently active + const available = mockAliens.filter(a => + a.distanceLY <= preferences.maxDistanceLY && + !matches.find(m => m.id === a.id) && + !dismissedIds.has(a.id) && + !activeIds.includes(a.id) + ); + + // Sort available aliens by compatibility descending, so the queue is ordered + // from highest match to lowest match. The next alien pulled will always be the highest remaining match. + available.sort((a, b) => getCompatibility(b, preferences) - getCompatibility(a, preferences)); + + let changed = false; + const newActiveIds = [...activeIds]; + + // Check each of the 5 tracks + for (let i = 0; i < 5; i++) { + const currentId = newActiveIds[i]; + // Check if the current alien in this track is still valid + const isStillValid = currentId && + mockAliens.find(a => a.id === currentId && a.distanceLY <= preferences.maxDistanceLY) && + !matches.find(m => m.id === currentId) && + !dismissedIds.has(currentId); + + if (!isStillValid) { + // The slot is empty or invalid, pull a new alien from available pool + const nextAlien = available.shift(); + newActiveIds[i] = nextAlien ? nextAlien.id : null; + changed = true; + } + } + + if (changed) { + setActiveIds(newActiveIds); + } + + // Trigger breaking animation if empty and not already shown + if (newActiveIds.every(id => id === null) && animStage === 'none') { + setAnimStage('heart'); + setTimeout(() => setAnimStage('break'), 1500); + setTimeout(() => setAnimStage('final'), 2500); + } else if (newActiveIds.some(id => id !== null)) { + setAnimStage('none'); + } + }, [preferences, matches, dismissedIds, activeIds, animStage]); if (!preferences) return null; - // Filter aliens based on max distance - const visibleAliens = mockAliens.filter(a => a.distanceAU <= preferences.maxDistanceAU); - const handleMatch = (alien: AlienProfile) => { + const warnings = getScientificWarnings(alien); + if (warnings.length > 0) { + // Hazards detected — close modal and show warning first + setSelectedAlien(null); + setPendingMatchAlien(alien); + } else { + // No hazards — proceed directly + addMatch(alien); + setSelectedAlien(null); + triggerRocketNav(`/chat/${alien.id}`, { alienImg: alien.profilePic }); + } + }; + + const confirmMatch = (alien: AlienProfile) => { addMatch(alien); - setSelectedAlien(null); - setMatchedAlien(alien); + setPendingMatchAlien(null); + triggerRocketNav(`/chat/${alien.id}`, { alienImg: alien.profilePic }); + }; + + const handleDismiss = (alien: AlienProfile) => { + setDismissedIds(prev => { + const newSet = new Set(prev); + newSet.add(alien.id); + return newSet; + }); + + // Find the next available alien in activeIds + const currentIndex = activeIds.indexOf(alien.id); + let nextId = null; + if (currentIndex >= 0) { + for(let i=1; i<5; i++) { + const candidate = activeIds[(currentIndex + i) % 5]; + if (candidate && candidate !== alien.id) { + nextId = candidate; + break; + } + } + } + + if (nextId) { + const nextAlien = mockAliens.find(a => a.id === nextId); + setSelectedAlien(nextAlien || null); + } else { + setSelectedAlien(null); + } }; + return ( <> -
- +
+ {/* User Center */}
- {preferences.name.substring(0, 2).toUpperCase() || 'YOU'} + {!preferences.profilePic && (preferences.name.substring(0, 2).toUpperCase() || 'YOU')}
{/* Orbit Rings and Aliens */} - {visibleAliens.map((alien, i) => { - // Calculate radius based on distance (min 80px, max 280px) - const radiusRatio = preferences.maxDistanceAU > 0 ? alien.distanceAU / preferences.maxDistanceAU : 1; - const radius = 80 + (radiusRatio * 200); - const duration = 15 + (radius / 10); // Slower orbit for further objects - const startAngle = (i * (360 / visibleAliens.length)); + {activeIds + .map(id => id ? mockAliens.find(a => a.id === id) : null) + .filter((a): a is AlienProfile => a !== null && a !== undefined) + // Sort by compatibility descending so the highest match is in the innermost track + .sort((a, b) => getCompatibility(b, preferences) - getCompatibility(a, preferences)) + .map((alien, i) => { + + // Assign each slot to a distinct track (0 to 4) + const rx = 120 + (i * 65); + const ry = 80 + (i * 40); + const duration = 15 + (i * 8); + + return ( -
+
{/* Ring */} -
- +
+ {/* Profile */} -
setSelectedAlien(alien)} style={{ backgroundImage: `url(${alien.profilePic})`, // @ts-ignore - '--radius': `${radius}px`, + '--rx': `${rx}px`, + '--ry': `${ry}px`, '--duration': `${duration}s`, - animationDelay: `-${startAngle}s` // Stagger start positions + animationName: `orbit-${i}` }} - title={`${alien.name} (${alien.distanceAU} AU)`} + title={`${alien.name} (${alien.distanceLY} Light years)`} >
); })} + + {/* Empty state message / Breaking Animation */} + {animStage !== 'none' && ( +
+ {(animStage === 'heart' || animStage === 'break') && ( +
+
+ +
+
+ +
+
+ )} + + {animStage === 'final' && ( +
+

+ The stars are not aligned +

+

+ Looks like you've run out of potential matches, check back later +

+ +
+ )} +
+ )}
{selectedAlien && ( - setSelectedAlien(null)} - onMatch={handleMatch} + setSelectedAlien(null)} + onMatch={handleMatch} + onDismiss={handleDismiss} /> )} - {matchedAlien && ( - confirmMatch(pendingMatchAlien)} + onCancel={() => { + handleDismiss(pendingMatchAlien); + setPendingMatchAlien(null); + }} /> )} + ); } diff --git a/src/components/ProfileModal.tsx b/src/components/ProfileModal.tsx index 97638a8..cd5945a 100644 --- a/src/components/ProfileModal.tsx +++ b/src/components/ProfileModal.tsx @@ -1,15 +1,72 @@ +import { useState, useEffect } from 'react'; import type { AlienProfile } from '../data/mockAliens'; -import { X, Heart, Info, Globe, Wind } from 'lucide-react'; +import { X, Heart, Info, Globe, Wind, Thermometer } from 'lucide-react'; +import { useAppContext } from '../context/AppContext'; +import { getCompatibility } from '../utils/compatibility'; +import { getScientificWarnings } from '../utils/scienceWarnings'; interface ProfileModalProps { alien: AlienProfile; onClose: () => void; onMatch: (alien: AlienProfile) => void; + onDismiss: (alien: AlienProfile) => void; } -export default function ProfileModal({ alien, onClose, onMatch }: ProfileModalProps) { +export default function ProfileModal({ alien, onClose, onMatch, onDismiss }: ProfileModalProps) { + const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null); + const { preferences } = useAppContext(); + const compatibility = getCompatibility(alien, preferences); + const warnings = getScientificWarnings(alien); + const warnedLabels = new Set(warnings.map(w => w.label.toLowerCase())); + + // Helper: return red/orange border colour if a stat is flagged by keyword match + const dangerFor = (key: string) => { + const w = warnings.find(w => w.label.toLowerCase().includes(key.toLowerCase())); + if (!w) return 'rgba(234, 222, 218, 0.1)'; + return w.severity === 'danger' ? 'rgba(220, 38, 38, 0.6)' : 'rgba(245, 158, 11, 0.6)'; + }; + + // Dedicated temperature check — catches all heat/cold warning labels + const dangerForTemp = () => { + const tempKeywords = ['heat', 'hot', 'cold', 'temperature']; + const w = warnings.find(w => + tempKeywords.some(k => w.label.toLowerCase().includes(k)) + ); + if (!w) return 'rgba(234, 222, 218, 0.1)'; + return w.severity === 'danger' ? 'rgba(220, 38, 38, 0.6)' : 'rgba(245, 158, 11, 0.6)'; + }; + void warnedLabels; + + useEffect(() => { + setSwipeDirection(null); + }, [alien.id]); + + const handleMatch = () => { + setSwipeDirection('right'); + setTimeout(() => onMatch(alien), 300); + }; + + const handleDismiss = () => { + setSwipeDirection('left'); + setTimeout(() => onDismiss(alien), 300); + }; + return ( -
+ +
-
+
+ + {/* Reject Button */} + + +
- -
+ + {/* Match Button */} +
+ ); } diff --git a/src/components/RocketTransition.tsx b/src/components/RocketTransition.tsx new file mode 100644 index 0000000..d95c814 --- /dev/null +++ b/src/components/RocketTransition.tsx @@ -0,0 +1,315 @@ +import { useEffect, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useNavigate } from 'react-router'; +import { useTransitionContext } from '../context/TransitionContext'; +import { useAppContext } from '../context/AppContext'; + +const KawaiiRocket = ({ alienImg }: { alienImg?: string }) => { + return ( + + {/* Fins Behind */} + + + + {/* Main Body */} + + + {/* Nose Cone */} + + + {/* Center Fin (Front) */} + + + {/* Window Outer */} + + + {/* Window Inner / Alien Face */} + + + + + + + + + {alienImg && ( + + )} + + {/* Shine on window */} + {!alienImg && } + + ); +}; + +export const RocketTransition = () => { + const { isLaunching, launchData, destination, finalizeNav } = useTransitionContext(); + const { preferences } = useAppContext(); + const [particles, setParticles] = useState([]); + const navigate = useNavigate(); + + const handleEnterChat = () => { + if (destination) { + navigate(destination); + finalizeNav(); + } + }; + + useEffect(() => { + if (isLaunching) { + setParticles(Array.from({ length: 120 }).map((_, i) => i)); + } else { + setTimeout(() => setParticles([]), 500); + } + }, [isLaunching]); + + return ( + <> + + + + + + + + + + + {isLaunching && ( + + {/* Fullscreen Gooey Container for Smoke and Heart */} +
+ {particles.map((i) => { + const delay = i * 0.025; // 120 particles * 0.025s = 3.0s total emission period + // Ends emitting smoke exactly when the heart is fully formed! + const progress = delay / 4.0; // rocket takes 4.0s total + const sidewaysBase = (Math.random() - 0.5); + return ( + + ); + })} +
+ + {/* The Crisp White Heart (Moved outside gooey filter so smoke collisions don't deform it) */} + + + + + {/* The Rocket Moving Up */} + + + + + {/* "IT'S A MATCH!" Text inside the Smoke Heart */} + + YOU'VE BEEN + STARSTRUCK! + + + {/* User and Match Avatars */} + + {/* User Avatar (Profile Pic or Initials) */} +
+ {preferences?.profilePic ? ( + You + ) : ( + {preferences?.name ? preferences.name.substring(0, 2).toUpperCase() : 'ME'} + )} +
+ + {/* Heart Icon */} + + + + + + + {/* Alien Avatar */} +
+ {launchData?.alienImg && ( + Alien + )} +
+
+ + {/* Manual Enter Chat Button */} + + { + e.stopPropagation(); + handleEnterChat(); + }} + style={{ + background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))', + color: 'white', + border: 'none', + padding: '18px 48px', + borderRadius: '40px', + fontSize: '1.3rem', + letterSpacing: '1px', + textTransform: 'uppercase', + fontWeight: '900', + boxShadow: '0 8px 30px rgba(217, 3, 104, 0.5)', + cursor: 'pointer' + }} + > + Open Transmission + + +
+ )} +
+ + ); +}; diff --git a/src/components/ScientificWarningModal.tsx b/src/components/ScientificWarningModal.tsx new file mode 100644 index 0000000..be0771b --- /dev/null +++ b/src/components/ScientificWarningModal.tsx @@ -0,0 +1,186 @@ +import type { AlienProfile } from '../data/mockAliens'; +import { getScientificWarnings } from '../utils/scienceWarnings'; +import { AlertTriangle, Skull, Heart, X } from 'lucide-react'; + +interface ScientificWarningModalProps { + alien: AlienProfile; + onProceed: () => void; + onCancel: () => void; +} + +export default function ScientificWarningModal({ alien, onProceed, onCancel }: ScientificWarningModalProps) { + const warnings = getScientificWarnings(alien); + const hasDanger = warnings.some(w => w.severity === 'danger'); + + return ( +
+ + +
+ {/* Header */} +
+
+ {hasDanger + ? + : + } +
+ +

+ Scientific Hazard Warning +

+

+ Travelling to {alien.name}'s home world poses{' '} + + {hasDanger ? 'life-threatening' : 'significant'} risks + {' '} + compared to Earth conditions. +

+
+ + {/* Warning list */} +
+ {warnings.map((w, i) => ( +
+
+ {w.severity === 'danger' + ? + : + } + + {w.label} + + + {w.alienValue} vs Earth {w.earthValue} + +
+

+ {w.description} +

+
+ ))} +
+ + {/* Proceed question */} +

+ Do you still want to match with {alien.name}? +

+ + {/* Action buttons */} +
+ + +
+
+
+ ); +} diff --git a/src/components/Starfield.tsx b/src/components/Starfield.tsx new file mode 100644 index 0000000..ada6bb4 --- /dev/null +++ b/src/components/Starfield.tsx @@ -0,0 +1,143 @@ +import { useEffect, useRef } from 'react'; + +interface Star { + x: number; + y: number; + size: number; + opacity: number; + speed: number; + twinkleFactor: number; + twinkleSpeed: number; +} + +interface ShootingStar { + x: number; + y: number; + len: number; + speed: number; + opacity: number; +} + +export const Starfield = () => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d', { alpha: true }); + if (!ctx) return; + + let stars: Star[] = []; + let shootingStar: ShootingStar | null = null; + let animationFrameId: number; + let w = window.innerWidth; + let h = window.innerHeight; + + const initStars = () => { + w = canvas.width = window.innerWidth; + h = canvas.height = window.innerHeight; + stars = []; + const starCount = Math.floor((w * h) / 6000); // Slightly fewer stars for better performance + for (let i = 0; i < starCount; i++) { + stars.push({ + x: Math.random() * w, + y: Math.random() * h, + size: Math.random() * 1.2 + 0.3, + opacity: Math.random() * 0.6 + 0.2, + speed: Math.random() * 0.04 + 0.01, + twinkleFactor: Math.random() * Math.PI, + twinkleSpeed: Math.random() * 0.02 + 0.005 + }); + } + }; + + const spawnShootingStar = () => { + if (shootingStar) return; + if (Math.random() < 0.998) return; // Rare spawn + + shootingStar = { + x: Math.random() * w, + y: Math.random() * (h / 2), + len: Math.random() * 80 + 50, + speed: Math.random() * 10 + 5, + opacity: 1 + }; + }; + + const drawStars = () => { + ctx.clearRect(0, 0, w, h); + + // Draw static stars + for (let i = 0; i < stars.length; i++) { + const star = stars[i]; + star.twinkleFactor += star.twinkleSpeed; + const currentOpacity = star.opacity * (0.4 + Math.abs(Math.cos(star.twinkleFactor)) * 0.6); + + star.y -= star.speed; + if (star.y < 0) { + star.y = h; + star.x = Math.random() * w; + } + + ctx.beginPath(); + ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255, 255, 255, ${currentOpacity})`; + ctx.fill(); + } + + // Draw & Update Shooting Star + spawnShootingStar(); + if (shootingStar) { + ctx.beginPath(); + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.strokeStyle = `rgba(217, 3, 104, ${shootingStar.opacity})`; + ctx.moveTo(shootingStar.x, shootingStar.y); + ctx.lineTo(shootingStar.x + shootingStar.len, shootingStar.y + (shootingStar.len / 2)); + ctx.stroke(); + + shootingStar.x += shootingStar.speed; + shootingStar.y += shootingStar.speed / 2; + shootingStar.opacity -= 0.02; + + if (shootingStar.opacity <= 0 || shootingStar.x > w || shootingStar.y > h) { + shootingStar = null; + } + } + + animationFrameId = requestAnimationFrame(drawStars); + }; + + const handleResize = () => { + initStars(); + }; + + window.addEventListener('resize', handleResize); + initStars(); + drawStars(); + + return () => { + window.removeEventListener('resize', handleResize); + cancelAnimationFrame(animationFrameId); + }; + }, []); + + return ( + + ); +}; diff --git a/src/components/UserProfileWidget.tsx b/src/components/UserProfileWidget.tsx new file mode 100644 index 0000000..d67785b --- /dev/null +++ b/src/components/UserProfileWidget.tsx @@ -0,0 +1,209 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { useAppContext } from '../context/AppContext'; +import { Settings, X, Edit2, MapPin, Globe, Zap, Target } from 'lucide-react'; + +export default function UserProfileWidget() { + const { preferences } = useAppContext(); + const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(false); + + if (!preferences) return null; + + return ( + <> + + + {/* Avatar Button */} + + + {/* Slide-out Panel */} + {isOpen && ( +
+ {/* Close button */} + + + {/* Profile Header */} +
+
+ {!preferences.profilePic && preferences.name.substring(0, 2).toUpperCase()} +
+
+

+ {preferences.name} +

+

+ {preferences.species} • {preferences.age && `${preferences.age} yrs`} +

+
+
+ + {/* Details */} +
+ {preferences.planet && ( +
+ + {preferences.planet} +
+ )} + {preferences.bio && ( +

+ "{preferences.bio.length > 80 ? preferences.bio.substring(0, 80) + '…' : preferences.bio}" +

+ )} +
+ + {/* Preferences Summary */} +
+

+ Match Preferences +

+
+
+ + Seeking: + {preferences.alienType} +
+
+ + Size: + {preferences.size} +
+
+ + Max distance: + {preferences.maxDistanceLY} LY +
+
+
+ + {/* Edit Buttons */} +
+ + +
+
+ )} + + {/* Click-outside backdrop (transparent) */} + {isOpen && ( +
setIsOpen(false)} + style={{ + position: 'fixed', + inset: 0, + zIndex: 198, + }} + /> + )} + + ); +} diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index a0c6d09..00e49c2 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -1,4 +1,5 @@ -import { createContext, useState, ReactNode, useContext } from 'react'; +import { createContext, useState, useContext, useEffect } from 'react'; +import type { ReactNode } from 'react'; import type { AlienProfile } from '../data/mockAliens'; export interface UserPreferences { @@ -11,7 +12,7 @@ export interface UserPreferences { limbs: number; alienType: string; size: string; - maxDistanceAU: number; + maxDistanceLY: number; goals: string; profilePic: string; // URL or base64 } @@ -19,6 +20,7 @@ export interface UserPreferences { interface AppContextType { preferences: UserPreferences | null; setPreferences: (prefs: UserPreferences) => void; + clearPreferences: () => void; matches: AlienProfile[]; addMatch: (alien: AlienProfile) => void; } @@ -26,13 +28,36 @@ interface AppContextType { export const AppContext = createContext(undefined); export const AppProvider = ({ children }: { children: ReactNode }) => { - const [preferences, setPreferencesState] = useState(null); - const [matches, setMatches] = useState([]); + const [preferences, setPreferencesState] = useState(() => { + const saved = localStorage.getItem('aligned_preferences'); + return saved ? JSON.parse(saved) : null; + }); + const [matches, setMatches] = useState(() => { + const saved = localStorage.getItem('aligned_matches'); + return saved ? JSON.parse(saved) : []; + }); + + useEffect(() => { + if (preferences) { + localStorage.setItem('aligned_preferences', JSON.stringify(preferences)); + } + }, [preferences]); + + useEffect(() => { + localStorage.setItem('aligned_matches', JSON.stringify(matches)); + }, [matches]); const setPreferences = (prefs: UserPreferences) => { setPreferencesState(prefs); }; + const clearPreferences = () => { + setPreferencesState(null); + setMatches([]); + localStorage.removeItem('aligned_preferences'); + localStorage.removeItem('aligned_matches'); + }; + const addMatch = (alien: AlienProfile) => { if (!matches.find(m => m.id === alien.id)) { setMatches([...matches, alien]); @@ -40,7 +65,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }; return ( - + {children} ); diff --git a/src/context/TransitionContext.tsx b/src/context/TransitionContext.tsx new file mode 100644 index 0000000..c1cf55d --- /dev/null +++ b/src/context/TransitionContext.tsx @@ -0,0 +1,64 @@ +import { createContext, useContext, useState } from 'react'; +import type { ReactNode } from 'react'; +import { motion } from 'framer-motion'; + +interface TransitionContextType { + isLaunching: boolean; + launchData: any; + destination: string | null; + triggerRocketNav: (path: string, data?: any) => void; + finalizeNav: () => void; +} + +export const TransitionContext = createContext(undefined); + + +export const TransitionProvider = ({ children }: { children: ReactNode }) => { + const [isLaunching, setIsLaunching] = useState(false); + const [launchData, setLaunchData] = useState(null); + const [destination, setDestination] = useState(null); + + const triggerRocketNav = (path: string, data?: any) => { + if (isLaunching) return; + setLaunchData(data); + setDestination(path); + setIsLaunching(true); + }; + + const finalizeNav = () => { + setIsLaunching(false); + setLaunchData(null); + setDestination(null); + }; + + return ( + + + {children} + + + ); +}; + +export const useRocketNav = () => { + const context = useContext(TransitionContext); + if (!context) { + throw new Error('useRocketNav must be used within a TransitionProvider'); + } + return context.triggerRocketNav; +}; + +export const useTransitionContext = () => { + const context = useContext(TransitionContext); + if (!context) { + throw new Error('useTransitionContext must be used within a TransitionProvider'); + } + return context; +}; diff --git a/src/data/mockAliens.ts b/src/data/mockAliens.ts index 8d0f982..99acbdd 100644 --- a/src/data/mockAliens.ts +++ b/src/data/mockAliens.ts @@ -4,9 +4,10 @@ export interface AlienProfile { alienType: string; limbs: number; size: string; // e.g. "Small", "Medium", "Large", "Colossal" - distanceAU: number; + distanceLY: number; profilePic: string; - + compatibilityPercent: number; // Randomly generated between 50 and 99.5 + // Dating Info bio: string; age: number; @@ -15,6 +16,7 @@ export interface AlienProfile { // Scientific Measures gravityGs: number; oxygenPercent: number; + homeTemperatureC: number; // Average surface temperature in Celsius planetType: 'Gas Giant' | 'Solid Ground' | 'All Water'; } @@ -22,46 +24,239 @@ export const mockAliens: AlienProfile[] = [ { id: "alien-1", name: "Glaxion", - alienType: "Neon Synth", + alienType: "Alien", limbs: 4, size: "Medium", - distanceAU: 4.2, + distanceLY: 4.2, profilePic: "/alien_glax.png", + compatibilityPercent: 87.4, bio: "Just a glowing being looking for someone to share binary sunsets with. I love long flights through nebula clouds.", age: 420, hobbies: ["Stargazing", "Quantum Chess", "Nebula Surfing"], gravityGs: 2.5, oxygenPercent: 5, + homeTemperatureC: 450, planetType: "Gas Giant" }, { id: "alien-2", name: "Zorblax", - alienType: "Aquatic Siren", + alienType: "Alien", limbs: 8, size: "Small", - distanceAU: 12.8, + distanceLY: 12.8, profilePic: "/alien_zorblax.png", + compatibilityPercent: 92.1, bio: "Water you up to? I'm quite the catch! Looking for a land-dweller to show me around the solid ground.", age: 185, hobbies: ["Deep Sea Weaving", "Coral Sculpting", "Hydro-acoustics"], gravityGs: 0.8, oxygenPercent: 12, + homeTemperatureC: 4, planetType: "All Water" }, { id: "alien-3", name: "Vex'tar", - alienType: "Draconian", + alienType: "Alien", limbs: 4, size: "Large", - distanceAU: 1.5, + distanceLY: 1.5, profilePic: "/alien_vex.png", + compatibilityPercent: 76.8, bio: "Tough scales, soft heart. Need someone who can handle the heat. Let's make some craters together.", age: 890, hobbies: ["Volcano Diving", "Asteroid Mining", "Heavy Metal (Literally)"], gravityGs: 1.5, oxygenPercent: 18, + homeTemperatureC: 280, + planetType: "Solid Ground" + }, + { + id: "alien-4", + name: "Crystalia", + alienType: "Alien", + limbs: 4, + size: "Medium", + distanceLY: 25.4, + profilePic: "/alien_crystal.png", + compatibilityPercent: 98.2, + bio: "I may look fragile, but my heart is a diamond. Looking for someone to refract the light of the cosmos with.", + age: 1200, + hobbies: ["Prism Alignment", "Sonic Resonance Meditation", "Jewelry Making"], + gravityGs: 1.2, + oxygenPercent: 21, + homeTemperatureC: 18, + planetType: "Solid Ground" + }, + { + id: "alien-5", + name: "Spore'rel", + alienType: "Alien", + limbs: 6, + size: "Small", + distanceLY: 8.9, + profilePic: "/alien_fungal.png", + compatibilityPercent: 88.5, + bio: "I'm a fun guy! (Get it?) Looking for a fertile mind to grow old together.", + age: 45, + hobbies: ["Decomposition", "Bioluminescent Dancing", "Networking (Mycelial)"], + gravityGs: 0.5, + oxygenPercent: 8, + homeTemperatureC: 10, + planetType: "Solid Ground" + }, + { + id: "alien-6", + name: "Lyra", + alienType: "Alien", + limbs: 0, + size: "Medium", + distanceLY: 55.0, + profilePic: "/alien_plasma.png", + compatibilityPercent: 61.3, + bio: "Pure energy trapped in a containment suit. Hoping to find a spark. Warning: Handle with insulated gloves.", + age: 8000, + hobbies: ["Solar Surfing", "Fusion Cooking", "Philosophy"], + gravityGs: 10.0, + oxygenPercent: 0, + homeTemperatureC: 6000, + planetType: "Gas Giant" + }, + { + id: "alien-7", + name: "Lumina", + alienType: "Hybrid", + limbs: 4, + size: "Large", + distanceLY: 12.4, + profilePic: "/alien_lumina.webp", + compatibilityPercent: 94.2, + bio: "I can show you the stars, literally. Looking for an Earthling to share my cosmic wisdom and glowing energy with.", + age: 3500, + hobbies: ["Energy Channeling", "Forest Walks", "Astral Projection"], + gravityGs: 1.0, + oxygenPercent: 21, + homeTemperatureC: 20, + planetType: "Solid Ground" + }, + { + id: "alien-8", + name: "Zarok", + alienType: "Hybrid", + limbs: 4, + size: "Medium", + distanceLY: 8.1, + profilePic: "/alien_zarok.webp", + compatibilityPercent: 65.8, + bio: "Serious, dedicated, and very logical. My third eye sees all possibilities. Seeking a companion who appreciates order.", + age: 154, + hobbies: ["Galactic Politics", "Telepathy", "Strategic Board Games"], + gravityGs: 1.5, + oxygenPercent: 16, + homeTemperatureC: 8, + planetType: "Solid Ground" + }, + { + id: "alien-9", + name: "Squish", + alienType: "Alien", + limbs: 0, + size: "Small", + distanceLY: 3.3, + profilePic: "/alien_squish.webp", + compatibilityPercent: 89.9, + bio: "I'm just a little guy! Extremely squishy and very affectionate. I will stick to you.", + age: 5, + hobbies: ["Absorbing Nutrients", "Bouncing", "Looking Cute"], + gravityGs: 0.8, + oxygenPercent: 25, + homeTemperatureC: 26, + planetType: "Solid Ground" + }, + { + id: "alien-10", + name: "Xeno", + alienType: "Alien", + limbs: 4, + size: "Colossal", + distanceLY: 35.0, + profilePic: "/alien_xeno.webp", + compatibilityPercent: 52.1, + bio: "Hiss... I mean, hello. Might look intimidating, but I'm really just a misunderstood collector of genetic material.", + age: 40, + hobbies: ["Hunting", "Acid Spitting", "Hiding in Vents"], + gravityGs: 2.0, + oxygenPercent: 5, + homeTemperatureC: -90, + planetType: "Gas Giant" + }, + { + id: "alien-11", + name: "Cyra", + alienType: "Hybrid", + limbs: 4, + size: "Medium", + distanceLY: 66.6, + profilePic: "/alien_cyra.jpeg", + compatibilityPercent: 78.4, + bio: "Drifting through the nebulas has made me quiet, but my bioluminescence speaks volumes.", + age: 950, + hobbies: ["Nebula Drifting", "Silent Meditation", "Cloak Making"], + gravityGs: 0.5, + oxygenPercent: 2, + homeTemperatureC: -210, + planetType: "Gas Giant" + }, + { + id: "alien-12", + name: "Ignis", + alienType: "Human", + limbs: 4, + size: "Medium", + distanceLY: 2.1, + profilePic: "/alien_ignis.jpeg", + compatibilityPercent: 81.5, + bio: "I bring greetings from the Scarlet Court. If you can handle high society and fiery personalities, we'll get along famously.", + age: 320, + hobbies: ["Diplomacy", "Fashion", "Sipping Plasma Wine"], + gravityGs: 1.2, + oxygenPercent: 18, + homeTemperatureC: 38, + planetType: "Solid Ground" + }, + { + id: "alien-13", + name: "Sparky", + alienType: "Alien", + limbs: 4, + size: "Small", + distanceLY: 18.9, + profilePic: "/alien_sparky.jpeg", + compatibilityPercent: 91.2, + bio: "I'm literally the light of the party! My head-stalks glow when I'm happy.", + age: 22, + hobbies: ["Creating Light Shows", "Exploring Dark Caves", "Eating Batteries"], + gravityGs: 0.7, + oxygenPercent: 15, + homeTemperatureC: 22, + planetType: "Solid Ground" + }, + { + id: "alien-14", + name: "Kaelen", + alienType: "Human", + limbs: 4, + size: "Medium", + distanceLY: 9.5, + profilePic: "/alien_kaelen.jpeg", + compatibilityPercent: 96.7, + bio: "Protector of the azure waterfalls. I'm looking for a partner to explore the wild flora and fauna of my home planet.", + age: 205, + hobbies: ["Vine Swinging", "Amulet Crafting", "Botany"], + gravityGs: 1.0, + oxygenPercent: 25, + homeTemperatureC: 34, planetType: "Solid Ground" } ]; diff --git a/src/index.css b/src/index.css index 6e18077..7fdbb92 100644 --- a/src/index.css +++ b/src/index.css @@ -15,14 +15,44 @@ body { margin: 0; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - background-color: var(--color-dark); + background-color: #0d0b21; /* Deeper space color */ background-image: - radial-gradient(circle at 15% 50%, rgba(130, 2, 99, 0.25), transparent 25%), - radial-gradient(circle at 85% 30%, rgba(217, 3, 104, 0.25), transparent 25%); + radial-gradient(circle at 20% 30%, rgba(130, 2, 99, 0.15), transparent 40%), + radial-gradient(circle at 80% 70%, rgba(217, 3, 104, 0.15), transparent 40%), + radial-gradient(circle at 50% 50%, rgba(46, 41, 78, 0.2), transparent 50%); background-attachment: fixed; color: var(--color-white); - min-height: 100vh; - overflow-x: hidden; + height: 100vh; + overflow: hidden; + position: relative; +} + +body::before, body::after { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: -2; + pointer-events: none; + opacity: 0.3; + will-change: transform; +} + +body::before { + background: radial-gradient(circle at 0% 0%, var(--color-primary), transparent 50%); + animation: nebula-drift 20s ease-in-out infinite alternate; +} + +body::after { + background: radial-gradient(circle at 100% 100%, var(--color-secondary), transparent 50%); + animation: nebula-drift 25s ease-in-out infinite alternate-reverse; +} + +@keyframes nebula-drift { + 0% { transform: scale(1) translate(0, 0); } + 100% { transform: scale(1.2) translate(5%, 5%); } } h1, h2, h3, h4, h5, h6 { @@ -49,88 +79,153 @@ button { /* Glassmorphism utilities */ .glass-panel { background: var(--glass-bg); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); border: 1px solid var(--glass-border); - border-radius: 16px; - box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); + border-radius: 20px; + box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.4); + transition: transform 0.3s ease, box-shadow 0.3s ease; } .btn-primary { background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%); color: var(--color-white); border: none; - padding: 12px 24px; + padding: 14px 28px; border-radius: 30px; - font-weight: 600; + font-weight: 700; font-size: 1rem; - transition: transform 0.2s ease, box-shadow 0.2s ease; - box-shadow: 0 4px 15px rgba(217, 3, 104, 0.4); + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + box-shadow: 0 4px 20px rgba(217, 3, 104, 0.4); + text-transform: uppercase; + letter-spacing: 0.05em; } .btn-primary:hover { - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(217, 3, 104, 0.6); + transform: translateY(-3px) scale(1.02); + box-shadow: 0 8px 25px rgba(217, 3, 104, 0.6); } .btn-primary:active { - transform: translateY(0); + transform: translateY(-1px); } .btn-outline { - background: transparent; + background: rgba(255, 255, 255, 0.05); color: var(--color-white); border: 2px solid var(--color-secondary); - padding: 10px 22px; + padding: 12px 26px; border-radius: 30px; - font-weight: 600; + font-weight: 700; font-size: 1rem; transition: all 0.2s ease; + backdrop-filter: blur(4px); } .btn-outline:hover { - background: rgba(217, 3, 104, 0.1); + background: rgba(217, 3, 104, 0.2); + box-shadow: 0 0 15px rgba(217, 3, 104, 0.3); } /* Form inputs */ +.form-group { + margin-bottom: 20px; + display: flex; + flex-direction: column; + gap: 8px; +} + +label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: rgba(234, 222, 218, 0.9); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-left: 4px; +} + input, select, textarea { width: 100%; - background: rgba(234, 222, 218, 0.1); - border: 1px solid rgba(234, 222, 218, 0.3); + background: rgba(234, 222, 218, 0.07); + border: 1px solid rgba(234, 222, 218, 0.15); color: var(--color-white); - padding: 12px 16px; - border-radius: 8px; + padding: 14px 18px; + border-radius: 12px; font-family: inherit; font-size: 1rem; - transition: border-color 0.2s ease, background 0.2s ease; + transition: all 0.3s ease; + box-sizing: border-box; } -input[type="range"] { - padding: 0; - border: none; - background: transparent; +select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23EADEDA' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 16px center; + background-size: 18px; + padding-right: 45px; + cursor: pointer; } input:focus, select:focus, textarea:focus { outline: none; border-color: var(--color-secondary); - background: rgba(234, 222, 218, 0.15); + background: rgba(234, 222, 218, 0.12); + box-shadow: 0 0 0 4px rgba(217, 3, 104, 0.15); } input::placeholder, textarea::placeholder { - color: rgba(234, 222, 218, 0.5); + color: rgba(234, 222, 218, 0.4); } select option { - background-color: var(--color-dark); + background-color: #1e1b3a; color: var(--color-white); + padding: 12px; +} + +/* Custom Range Slider */ +input[type="range"] { + -webkit-appearance: none; + height: 6px; + background: rgba(234, 222, 218, 0.1); + border-radius: 5px; + background-image: linear-gradient(var(--color-secondary), var(--color-secondary)); + background-repeat: no-repeat; + border: none; + padding: 0; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + height: 20px; + width: 20px; + border-radius: 50%; + background: var(--color-white); + cursor: pointer; + box-shadow: 0 0 15px rgba(217, 3, 104, 0.8), 0 0 5px rgba(217, 3, 104, 0.5); + border: 2px solid var(--color-secondary); + transition: transform 0.2s ease; +} + +input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +input[type="range"]::-webkit-slider-runnable-track { + -webkit-appearance: none; + box-shadow: none; + border: none; + background: transparent; } /* Base App Layout */ .app-container { display: flex; flex-direction: column; - min-height: 100vh; + height: 100vh; + overflow: hidden; } .navbar { @@ -160,4 +255,13 @@ select option { flex: 1; display: flex; flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + overflow: hidden; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } } diff --git a/src/main.tsx b/src/main.tsx index cdab637..a22581a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router' import { AppProvider } from './context/AppContext' +import { TransitionProvider } from './context/TransitionContext' import './index.css' import App from './App.tsx' @@ -9,7 +10,9 @@ createRoot(document.getElementById('root')!).render( - + + + , diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index 4961fd7..f41b4ce 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -1,12 +1,160 @@ +import { useState, useRef, useEffect } from 'react'; import { useParams, Link } from 'react-router'; import { useAppContext } from '../context/AppContext'; +import { X } from 'lucide-react'; + +// Mock AI responses as fallback +const ALIEN_RESPONSES = [ + "My optic sensors appreciate your visual symmetry. Shall we exchange genetic material samples?", + "Your carbon-based form is adequate. How many standard galactic rotations have you survived?", + "I observe you only have two manipulating appendages. How do you efficiently consume nutrient paste?", + "Your transmission implies affectionate intent. My emotion-processing subroutines are currently downloading an update.", + "Interesting. Most species on my planet communicate via scent-gland excretion. Your vocal vibrations are... quaint.", + "Warning: Your bio-signature is dangerously attractive. Please lower your gravitational pull.", + "Are you emitting pheromones or is my atmospheric analyzer malfunctioning?", +]; + +const TRANSLATOR_GLITCH_RESPONSES = [ + "You are very shiny to my eyes. I forget how to speak my own moon language.", + "My heart thumps like a heavy moon-rock when I see your handsome face.", + "You are the premium human. My thoughts are messy like a asteroid belt.", + "Your smile is very bright. It makes me feel warm in my squishy parts.", + "I am looking at you so much that I forgot to breathe my air mixture.", + "You are very precious. Like a rare planet-crystal from the deep pits.", + "My antenna are doing the happy dance. You are very good looking today." +]; + +const LOADING_MESSAGES = [ + "Translating message...", + "Sending message...", + "Receiving message..." +]; + +type Message = { + id: string; + sender: 'user' | 'alien'; + text: string; +}; export default function Chat() { const { id } = useParams(); const { matches } = useAppContext(); + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isTranslating, setIsTranslating] = useState(false); + const [loadingStep, setLoadingStep] = useState(0); + const messagesEndRef = useRef(null); + const alien = matches.find(m => m.id === id); + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages, isTranslating]); + + useEffect(() => { + let interval: ReturnType; + if (isTranslating) { + setLoadingStep(0); + interval = setInterval(() => { + setLoadingStep(s => (s + 1) % LOADING_MESSAGES.length); + }, 1200); + } + return () => clearInterval(interval); + }, [isTranslating]); + + // Simulate an AI or API call to get the alien's response + const generateAlienResponse = async (userText: string, currentMessages: Message[]) => { + // Check if the user has provided a Gemini API Key in their environment variables (optional AI integration) + const geminiApiKey = import.meta.env.VITE_GEMINI_API_KEY; + + if (geminiApiKey && geminiApiKey !== 'YOUR_GEMINI_API_KEY_HERE') { + // Format the last few messages for conversational context + const chatHistory = currentMessages.slice(-4).map(m => + `${m.sender === 'user' ? 'Human' : alien?.name}: ${m.text}` + ).join('\n'); + + const promptText = `You are a user named ${alien?.name} on the dating app "ALIGNED". +${chatHistory ? `Here is the recent chat history:\n${chatHistory}\n` : ''} +Human: "${userText}" + +Reply directly to the Human's latest message as ${alien?.name}. +The twist: Act like your response went through a VERY CHEAP intergalactic translation software. +You must use VERY SIMPLE, basic English. However, insert exactly 1 or 2 awkwardly literal or slightly confusing words that sound like a funny misunderstanding of human culture. DO NOT be creepy, gross, or overly biological. Keep it harmless and charmingly awkward. + +For example: +- Instead of "You have beautiful eyes", say: "Your visual orbs are very shiny... good looking." +- Instead of "I am doing well", say: "I am having an excellent rotation today." +- Instead of "What are you doing?", say: "What hobbies are your human hands performing?" + +Keep it friendly, slightly flirtatious, and warmly confusing. Keep the grammar simple like a bad tourist translator, but drop in that one hilariously literal phrase. Maximum 2 sentences. Do not prefix the text with your name.`; + + try { + const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent?key=${geminiApiKey}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + contents: [{ + parts: [{ text: promptText }] + }] + }) + }); + + const data = await response.json(); + if (data.error) { + console.error("Gemini API Error:", data.error); + return TRANSLATOR_GLITCH_RESPONSES[Math.floor(Math.random() * TRANSLATOR_GLITCH_RESPONSES.length)]; + } + + if (data && data.candidates && data.candidates.length > 0) { + return data.candidates[0].content.parts[0].text; + } + } catch (e) { + console.error("AI translation failed:", e); + return TRANSLATOR_GLITCH_RESPONSES[Math.floor(Math.random() * TRANSLATOR_GLITCH_RESPONSES.length)]; + } + } + + // Fallback ONLY if there is no API key configured at all + return ALIEN_RESPONSES[Math.floor(Math.random() * ALIEN_RESPONSES.length)]; + }; + + const handleSendMessage = async () => { + if (!inputValue.trim() || isTranslating) return; + + const userMessage: Message = { + id: Date.now().toString(), + sender: 'user', + text: inputValue.trim() + }; + + setMessages(prev => [...prev, userMessage]); + setInputValue(''); + setIsTranslating(true); + + // Simulate "Processing Translation..." delay + const delay = Math.floor(Math.random() * 1500) + 3500; // 3.5s - 5.0s delay + + setTimeout(async () => { + // Pass the previous messages list array AND the new user message + const responseText = await generateAlienResponse(userMessage.text, [...messages, userMessage]); + const alienMessage: Message = { + id: (Date.now() + 1).toString(), + sender: 'alien', + text: responseText + }; + + setMessages(prev => [...prev, alienMessage]); + setIsTranslating(false); + }, delay); + }; + if (!alien) { return (
@@ -18,29 +166,95 @@ export default function Chat() { } return ( -
-
+
+
+ + Exit + +
+
{/* Chat Header */} -
+
+
{alien.name}

{alien.name}

- {alien.alienType} • {alien.distanceAU} AU away + {alien.alienType} • {alien.distanceLY} LY away
{/* Chat Messages */} -
-
- Connection established across {alien.distanceAU} AU. Say hello! -
+
+ {messages.length === 0 && ( +
+ Connection established across {alien.distanceLY} LY. Say hello! +
+ )} + + {messages.map((msg) => ( +
+ {msg.text} +
+ ))} + + {isTranslating && ( +
+ + {LOADING_MESSAGES[loadingStep]} +
+ )} + +
{/* Chat Input */} -
- - +
+
+ setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSendMessage(); + }} + disabled={isTranslating} + /> +
diff --git a/src/pages/Explore.tsx b/src/pages/Explore.tsx index 5b6d2bc..ae3e948 100644 --- a/src/pages/Explore.tsx +++ b/src/pages/Explore.tsx @@ -17,10 +17,26 @@ export default function Explore() { if (!preferences) return null; return ( -
-

Exploring Sector...

-

Scanning for matches within {preferences.maxDistanceAU} AU

- +
+

+ Exploring Sector... +

+

+ Scanning for potential matches within {preferences.maxDistanceLY} LY +

+
); diff --git a/src/pages/Preferences.tsx b/src/pages/Preferences.tsx index 2463794..0e1e3b3 100644 --- a/src/pages/Preferences.tsx +++ b/src/pages/Preferences.tsx @@ -5,11 +5,11 @@ import { useAppContext } from '../context/AppContext'; export default function Preferences() { const navigate = useNavigate(); const { preferences, setPreferences } = useAppContext(); - + const [formData, setFormData] = useState({ alienType: preferences?.alienType === 'Humanoid' || !preferences?.alienType ? 'Open to all' : preferences.alienType, size: preferences?.size || 'No preference', - maxDistanceAU: preferences?.maxDistanceAU || 10, + maxDistanceLY: preferences?.maxDistanceLY || 10, goals: preferences?.goals || 'Long term fusion', }); @@ -28,72 +28,83 @@ export default function Preferences() { const { name, value } = e.target; setFormData(prev => ({ ...prev, - [name]: name === 'maxDistanceAU' ? Number(value) : value + [name]: name === 'maxDistanceLY' ? Number(value) : value })); }; return ( -
-
-

Your Preferences

-

- What kind of being are you looking for? -

- -
- -
-

Biological Preferences

- -
- - -
+
+
+
+

Your Preferences

+

+ What kind of being are you looking for? +

-
- - -
-
+ + +
+

Biological Preferences

-
-

Relationship Goals

- -
- - +
+ + +
+ +
+ + +
- -
- -
- - {formData.maxDistanceAU} + +
+

Relationship Goals

+ +
+ + +
+ +
+ +
+ + {formData.maxDistanceLY} +
-
- -
- - -
- + +
+ + +
+ +
); diff --git a/src/pages/Signup.tsx b/src/pages/Signup.tsx index 922ff67..db5e943 100644 --- a/src/pages/Signup.tsx +++ b/src/pages/Signup.tsx @@ -10,7 +10,7 @@ export default function Signup() { const fileInputRef = useRef(null); const [tempPhoto, setTempPhoto] = useState(null); const [isHoveringPhoto, setIsHoveringPhoto] = useState(false); - + const [formData, setFormData] = useState({ name: preferences?.name || '', age: preferences?.age || '', @@ -23,11 +23,11 @@ export default function Signup() { limbs: preferences?.limbs || 4, alienType: preferences?.alienType || 'Open to all', size: preferences?.size || 'No preference', - maxDistanceAU: preferences?.maxDistanceAU || 10, + maxDistanceLY: preferences?.maxDistanceLY || 100, goals: preferences?.goals || 'Long term fusion', }); const [interestInput, setInterestInput] = useState(''); - const interestSuggestions = ['Stargazing', 'Volcano Diving', 'Heavy Metal (Literally)', 'Asteroid Mining']; + const interestSuggestions: any[] = []; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -103,187 +103,195 @@ export default function Signup() { if (tempPhoto) { return ( - ); } return ( -
-
-

Welcome to SSTRUK

-

- Enter your details -

- -
- -
-
setIsHoveringPhoto(true)} - onMouseLeave={() => setIsHoveringPhoto(false)} - style={{ - width: '100px', - height: '100px', - borderRadius: '50%', - backgroundColor: 'rgba(255,255,255,0.1)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - cursor: 'pointer', - border: formData.profilePic ? 'none' : '2px dashed rgba(255,255,255,0.3)', - overflow: 'hidden', - position: 'relative' - }} - > - - {formData.profilePic ? ( - <> - Profile - {isHoveringPhoto && ( -
- -
- )} - - ) : ( - - Add profile photo - - )} + +
+ )} + + ) : ( + + Add profile photo + + )} +
-
-
- - -
- -
-
- - +
+ +
-
- - -
-
-
- - -
- -
- -