From a4e3baf4e8c86167b4f60e37b112b2c80db3a2f4 Mon Sep 17 00:00:00 2001 From: aman Date: Fri, 20 Feb 2026 13:07:19 +0530 Subject: [PATCH 01/11] feat(auth): login/signup page, api service created --- CLAUDE.md | 38 + app/components/MusicVisual/constants.js | 31 + app/components/MusicVisual/index.js | 41 + .../tests/__snapshots__/index.test.js.snap | 1015 + .../MusicVisual/tests/index.test.js | 25 + app/components/styled/authForm.js | 130 + app/components/styled/authLayout.js | 39 + app/components/styled/musicVisual.js | 130 + app/containers/Auth/LoginForm.js | 76 + app/containers/Auth/SignupForm.js | 89 + app/containers/Auth/reducer.js | 48 + app/containers/Auth/saga.js | 30 + app/containers/Auth/selectors.js | 13 + app/containers/Auth/tests/LoginForm.test.js | 54 + app/containers/Auth/tests/SignupForm.test.js | 62 + .../__snapshots__/LoginForm.test.js.snap | 222 + .../__snapshots__/SignupForm.test.js.snap | 241 + app/containers/Auth/tests/reducer.test.js | 62 + app/containers/Auth/tests/saga.test.js | 75 + app/containers/Auth/tests/selectors.test.js | 46 + app/reducers.js | 4 +- app/services/authApi.js | 7 + app/utils/apiUtils.js | 6 +- pages/_document.js | 9 +- pages/login.js | 45 + pages/signup.js | 45 + yarn.lock | 27743 +++++++--------- 27 files changed, 14463 insertions(+), 15863 deletions(-) create mode 100644 CLAUDE.md create mode 100644 app/components/MusicVisual/constants.js create mode 100644 app/components/MusicVisual/index.js create mode 100644 app/components/MusicVisual/tests/__snapshots__/index.test.js.snap create mode 100644 app/components/MusicVisual/tests/index.test.js create mode 100644 app/components/styled/authForm.js create mode 100644 app/components/styled/authLayout.js create mode 100644 app/components/styled/musicVisual.js create mode 100644 app/containers/Auth/LoginForm.js create mode 100644 app/containers/Auth/SignupForm.js create mode 100644 app/containers/Auth/reducer.js create mode 100644 app/containers/Auth/saga.js create mode 100644 app/containers/Auth/selectors.js create mode 100644 app/containers/Auth/tests/LoginForm.test.js create mode 100644 app/containers/Auth/tests/SignupForm.test.js create mode 100644 app/containers/Auth/tests/__snapshots__/LoginForm.test.js.snap create mode 100644 app/containers/Auth/tests/__snapshots__/SignupForm.test.js.snap create mode 100644 app/containers/Auth/tests/reducer.test.js create mode 100644 app/containers/Auth/tests/saga.test.js create mode 100644 app/containers/Auth/tests/selectors.test.js create mode 100644 app/services/authApi.js create mode 100644 pages/login.js create mode 100644 pages/signup.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4803f7b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ + +## Code Rules +- Follow ESLint config defined in `./eslint.config.mjs` +- Max cyclomatic complexity per function: **5** +- Max parameters per function: **3** +- Max file length: **100 lines** — break into smaller files if exceeded +- Extract shared/common logic into utils +- Keep constants in a separate constants file +- Write proper test cases for every feature +- Build small features first before moving forward + + +DISTILLED_AESTHETICS_PROMPT = """ + +You tend to converge toward generic, "on distribution" outputs. In frontend design, this creates what users call the "AI slop" aesthetic. Avoid this: make creative, distinctive frontends that surprise and delight. Focus on: + +Typography: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics. + +Color & Theme: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Draw from Music Application themes and cultural aesthetics for inspiration. + +Motion: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. + +Backgrounds: Create atmosphere and depth rather than defaulting to solid colors. Layer CSS gradients, use geometric patterns, or add contextual effects that match the overall aesthetic. + +Avoid generic AI-generated aesthetics: +- Overused font families (Inter, Roboto, Arial, system fonts) +- Clichéd color schemes (particularly purple gradients on white backgrounds) +- Predictable layouts and component patterns +- Cookie-cutter design that lacks context-specific character + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. Vary between light and dark themes, different fonts, different aesthetics. You still tend to converge on common choices (Space Grotesk, for example) across generations. Avoid this: it is critical that you think outside the box! + +""" + + + + + diff --git a/app/components/MusicVisual/constants.js b/app/components/MusicVisual/constants.js new file mode 100644 index 0000000..65cb9bc --- /dev/null +++ b/app/components/MusicVisual/constants.js @@ -0,0 +1,31 @@ +export const EQUALIZER_BARS = [ + { duration: 0.8, delay: 0 }, + { duration: 1.2, delay: 0.1 }, + { duration: 0.9, delay: 0.2 }, + { duration: 1.4, delay: 0.3 }, + { duration: 0.7, delay: 0.1 }, + { duration: 1.1, delay: 0.4 }, + { duration: 0.85, delay: 0.15 }, + { duration: 1.3, delay: 0.25 }, + { duration: 0.95, delay: 0.35 }, + { duration: 1.15, delay: 0.05 }, + { duration: 0.75, delay: 0.2 }, + { duration: 1.0, delay: 0.3 }, + { duration: 0.88, delay: 0.12 }, + { duration: 1.22, delay: 0.38 } +]; + +export const FLOATING_NOTES = [ + { note: '\u266A', size: 20, duration: 5, delay: 0, left: 18, bottom: 15 }, + { note: '\u266B', size: 28, duration: 7, delay: 2, left: 72, bottom: 20 }, + { note: '\u266A', size: 16, duration: 6, delay: 4, left: 38, bottom: 10 }, + { note: '\u266B', size: 24, duration: 8, delay: 1, left: 82, bottom: 28 }, + { note: '\u266A', size: 18, duration: 5.5, delay: 3, left: 12, bottom: 32 }, + { note: '\u266B', size: 22, duration: 6.5, delay: 5, left: 55, bottom: 8 } +]; + +export const GLOW_RINGS = [ + { size: 300, duration: 3, delay: 0 }, + { size: 370, duration: 4, delay: 0.5 }, + { size: 440, duration: 5, delay: 1 } +]; diff --git a/app/components/MusicVisual/index.js b/app/components/MusicVisual/index.js new file mode 100644 index 0000000..516f46c --- /dev/null +++ b/app/components/MusicVisual/index.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { + VinylRecord, + GlowRing, + EqualizerWrapper, + EqualizerBar, + FloatingNote, + BrandText, + TaglineText +} from '@components/styled/musicVisual'; +import { EQUALIZER_BARS, FLOATING_NOTES, GLOW_RINGS } from './constants'; + +const MusicVisual = () => ( + <> + MUSICA + FEEL THE RHYTHM + {GLOW_RINGS.map((ring) => ( + + ))} + + + {EQUALIZER_BARS.map((bar, i) => ( + + ))} + + {FLOATING_NOTES.map((note, i) => ( + + {note.note} + + ))} + +); + +export default MusicVisual; diff --git a/app/components/MusicVisual/tests/__snapshots__/index.test.js.snap b/app/components/MusicVisual/tests/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..e4f6513 --- /dev/null +++ b/app/components/MusicVisual/tests/__snapshots__/index.test.js.snap @@ -0,0 +1,1015 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render and match the snapshot 1`] = ` +@keyframes animation-0 { + 0%, 100% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + opacity: 0.15; + } + + 50% { + -webkit-transform: scale(1.08); + -moz-transform: scale(1.08); + -ms-transform: scale(1.08); + transform: scale(1.08); + opacity: 0.35; + } +} + +@keyframes animation-0 { + 0%, 100% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + opacity: 0.15; + } + + 50% { + -webkit-transform: scale(1.08); + -moz-transform: scale(1.08); + -ms-transform: scale(1.08); + transform: scale(1.08); + opacity: 0.35; + } +} + +@keyframes animation-0 { + 0%, 100% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + opacity: 0.15; + } + + 50% { + -webkit-transform: scale(1.08); + -moz-transform: scale(1.08); + -ms-transform: scale(1.08); + transform: scale(1.08); + opacity: 0.35; + } +} + +@keyframes animation-1 { + from { + -webkit-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -ms-transform: rotate(0deg); + transform: rotate(0deg); + } + + to { + -webkit-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +.emotion-0 { + font-family: 'Syne',sans-serif; + font-size: 3.2rem; + font-weight: 800; + letter-spacing: 0.3em; + color: #ffffff; + margin-bottom: 0.5rem; + z-index: 3; + text-shadow: 0 0 40px rgba(255, 107, 53, 0.25); +} + +.emotion-1 { + font-family: 'Outfit',sans-serif; + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.35); + letter-spacing: 0.25em; + z-index: 3; + margin-top: 0; + margin-bottom: 2.5rem; +} + +.emotion-2 { + position: absolute; + width: 300px; + height: 300px; + border-radius: 50%; + border: 1px solid rgba(255, 107, 53, 0.08); + -webkit-animation: animation-0 3s ease-in-out infinite; + animation: animation-0 3s ease-in-out infinite; + -webkit-animation-delay: 0s; + animation-delay: 0s; +} + +.emotion-3 { + position: absolute; + width: 370px; + height: 370px; + border-radius: 50%; + border: 1px solid rgba(255, 107, 53, 0.08); + -webkit-animation: animation-0 4s ease-in-out infinite; + animation: animation-0 4s ease-in-out infinite; + -webkit-animation-delay: 0.5s; + animation-delay: 0.5s; +} + +.emotion-4 { + position: absolute; + width: 440px; + height: 440px; + border-radius: 50%; + border: 1px solid rgba(255, 107, 53, 0.08); + -webkit-animation: animation-0 5s ease-in-out infinite; + animation: animation-0 5s ease-in-out infinite; + -webkit-animation-delay: 1s; + animation-delay: 1s; +} + +.emotion-5 { + width: 260px; + height: 260px; + border-radius: 50%; + background: radial-gradient( + circle, + #1a1a2e 0%, + #0d0d0d 20%, + #2d1b4e 21%, + #0d0d0d 40%, + #2d1b4e 41%, + #0d0d0d 60%, + #2d1b4e 61%, + #0d0d0d 80%, + #1a1a2e 100% + ); + -webkit-animation: animation-1 8s linear infinite; + animation: animation-1 8s linear infinite; + position: relative; + z-index: 2; + box-shadow: 0 0 60px rgba(255, 107, 53, 0.12); + cursor: pointer; + -webkit-transition: box-shadow 0.4s ease; + transition: box-shadow 0.4s ease; +} + +.emotion-5:hover { + -webkit-animation-duration: 2s; + animation-duration: 2s; + box-shadow: 0 0 90px rgba(232, 67, 147, 0.25); +} + +.emotion-5::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + -webkit-transform: translate(-50%, -50%); + -moz-transform: translate(-50%, -50%); + -ms-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + width: 55px; + height: 55px; + border-radius: 50%; + background: radial-gradient(circle, #ff6b35 0%, #e84393 100%); + box-shadow: 0 0 25px rgba(255, 107, 53, 0.5); +} + +.emotion-6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: flex-end; + -webkit-box-align: flex-end; + -ms-flex-align: flex-end; + align-items: flex-end; + gap: 5px; + height: 100px; + position: absolute; + bottom: 70px; + z-index: 1; +} + +.emotion-7 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.8s ease-in-out infinite; + animation: animation-2 0.8s ease-in-out infinite; + -webkit-animation-delay: 0s; + animation-delay: 0s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-7:hover { + opacity: 1; +} + +.emotion-8 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.2s ease-in-out infinite; + animation: animation-2 1.2s ease-in-out infinite; + -webkit-animation-delay: 0.1s; + animation-delay: 0.1s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-8:hover { + opacity: 1; +} + +.emotion-9 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.9s ease-in-out infinite; + animation: animation-2 0.9s ease-in-out infinite; + -webkit-animation-delay: 0.2s; + animation-delay: 0.2s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-9:hover { + opacity: 1; +} + +.emotion-10 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.4s ease-in-out infinite; + animation: animation-2 1.4s ease-in-out infinite; + -webkit-animation-delay: 0.3s; + animation-delay: 0.3s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-10:hover { + opacity: 1; +} + +.emotion-11 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.7s ease-in-out infinite; + animation: animation-2 0.7s ease-in-out infinite; + -webkit-animation-delay: 0.1s; + animation-delay: 0.1s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-11:hover { + opacity: 1; +} + +.emotion-12 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.1s ease-in-out infinite; + animation: animation-2 1.1s ease-in-out infinite; + -webkit-animation-delay: 0.4s; + animation-delay: 0.4s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-12:hover { + opacity: 1; +} + +.emotion-13 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.85s ease-in-out infinite; + animation: animation-2 0.85s ease-in-out infinite; + -webkit-animation-delay: 0.15s; + animation-delay: 0.15s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-13:hover { + opacity: 1; +} + +.emotion-14 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.3s ease-in-out infinite; + animation: animation-2 1.3s ease-in-out infinite; + -webkit-animation-delay: 0.25s; + animation-delay: 0.25s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-14:hover { + opacity: 1; +} + +.emotion-15 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.95s ease-in-out infinite; + animation: animation-2 0.95s ease-in-out infinite; + -webkit-animation-delay: 0.35s; + animation-delay: 0.35s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-15:hover { + opacity: 1; +} + +.emotion-16 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.15s ease-in-out infinite; + animation: animation-2 1.15s ease-in-out infinite; + -webkit-animation-delay: 0.05s; + animation-delay: 0.05s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-16:hover { + opacity: 1; +} + +.emotion-17 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.75s ease-in-out infinite; + animation: animation-2 0.75s ease-in-out infinite; + -webkit-animation-delay: 0.2s; + animation-delay: 0.2s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-17:hover { + opacity: 1; +} + +.emotion-18 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1s ease-in-out infinite; + animation: animation-2 1s ease-in-out infinite; + -webkit-animation-delay: 0.3s; + animation-delay: 0.3s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-18:hover { + opacity: 1; +} + +.emotion-19 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.88s ease-in-out infinite; + animation: animation-2 0.88s ease-in-out infinite; + -webkit-animation-delay: 0.12s; + animation-delay: 0.12s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-19:hover { + opacity: 1; +} + +.emotion-20 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.22s ease-in-out infinite; + animation: animation-2 1.22s ease-in-out infinite; + -webkit-animation-delay: 0.38s; + animation-delay: 0.38s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-20:hover { + opacity: 1; +} + +.emotion-21 { + position: absolute; + font-size: 20px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 5s ease-out infinite; + animation: animation-3 5s ease-out infinite; + -webkit-animation-delay: 0s; + animation-delay: 0s; + left: 18%; + bottom: 15%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-22 { + position: absolute; + font-size: 28px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 7s ease-out infinite; + animation: animation-3 7s ease-out infinite; + -webkit-animation-delay: 2s; + animation-delay: 2s; + left: 72%; + bottom: 20%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-23 { + position: absolute; + font-size: 16px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 6s ease-out infinite; + animation: animation-3 6s ease-out infinite; + -webkit-animation-delay: 4s; + animation-delay: 4s; + left: 38%; + bottom: 10%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-24 { + position: absolute; + font-size: 24px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 8s ease-out infinite; + animation: animation-3 8s ease-out infinite; + -webkit-animation-delay: 1s; + animation-delay: 1s; + left: 82%; + bottom: 28%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-25 { + position: absolute; + font-size: 18px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 5.5s ease-out infinite; + animation: animation-3 5.5s ease-out infinite; + -webkit-animation-delay: 3s; + animation-delay: 3s; + left: 12%; + bottom: 32%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-26 { + position: absolute; + font-size: 22px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 6.5s ease-out infinite; + animation: animation-3 6.5s ease-out infinite; + -webkit-animation-delay: 5s; + animation-delay: 5s; + left: 55%; + bottom: 8%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + + +
+

+ MUSICA +

+

+ FEEL THE RHYTHM +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ♪ +
+
+ ♫ +
+
+ ♪ +
+
+ ♫ +
+
+ ♪ +
+
+ ♫ +
+
+ +`; diff --git a/app/components/MusicVisual/tests/index.test.js b/app/components/MusicVisual/tests/index.test.js new file mode 100644 index 0000000..a80d180 --- /dev/null +++ b/app/components/MusicVisual/tests/index.test.js @@ -0,0 +1,25 @@ +import React from 'react' +import { renderProvider } from '@utils/testUtils' +import MusicVisual from '../index' + +describe('', () => { + it('should render and match the snapshot', () => { + const { baseElement } = renderProvider() + expect(baseElement).toMatchSnapshot() + }) + + it('should render the vinyl record', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('vinyl-record')).toBeTruthy() + }) + + it('should render the brand text', () => { + const { getByText } = renderProvider() + expect(getByText('MUSICA')).toBeTruthy() + }) + + it('should render the tagline', () => { + const { getByText } = renderProvider() + expect(getByText('FEEL THE RHYTHM')).toBeTruthy() + }) +}) diff --git a/app/components/styled/authForm.js b/app/components/styled/authForm.js new file mode 100644 index 0000000..58aa25f --- /dev/null +++ b/app/components/styled/authForm.js @@ -0,0 +1,130 @@ +import styled from '@emotion/styled'; + +const C = { + bg: '#161622', + inputBg: '#1e1e30', + border: '#2d2d44', + accent: '#ff6b35', + pink: '#e84393', + text: '#ffffff', + muted: '#6c7086', + label: '#9ca0b0', + placeholder: '#4a4a5a', + error: '#ff4757' +}; + +export const FormCard = styled.div` + width: 100%; + max-width: 420px; + padding: 3rem; + background: ${C.bg}; + border-radius: 16px; + border: 1px solid ${C.border}; +`; + +export const FormTitle = styled.h2` + font-family: 'Syne', sans-serif; + font-size: 2rem; + font-weight: 700; + color: ${C.text}; + margin-bottom: 0.5rem; +`; + +export const FormSubtitle = styled.p` + font-family: 'Outfit', sans-serif; + font-size: 0.95rem; + color: ${C.muted}; + margin-bottom: 2rem; +`; + +export const InputLabel = styled.label` + font-family: 'Outfit', sans-serif; + font-size: 0.85rem; + color: ${C.label}; + display: block; + margin-bottom: 0.5rem; +`; + +export const StyledInput = styled.input` + width: 100%; + padding: 0.85rem 1rem; + background: ${C.inputBg}; + border: 1.5px solid ${C.border}; + border-radius: 10px; + color: ${C.text}; + font-family: 'Outfit', sans-serif; + font-size: 0.95rem; + outline: none; + transition: all 0.3s ease; + box-sizing: border-box; + &::placeholder { + color: ${C.placeholder}; + } + &:focus { + border-color: ${C.accent}; + box-shadow: 0 0 20px rgba(255, 107, 53, 0.15); + } + &:hover:not(:focus) { + border-color: #3d3d54; + } +`; + +export const InputWrapper = styled.div` + margin-bottom: 1.25rem; +`; + +export const SubmitButton = styled.button` + width: 100%; + padding: 0.9rem; + background: linear-gradient(135deg, ${C.accent}, ${C.pink}); + border: none; + border-radius: 10px; + color: ${C.text}; + font-family: 'Syne', sans-serif; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + margin-top: 0.5rem; + letter-spacing: 0.05em; + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(255, 107, 53, 0.3); + } + &:active { + transform: translateY(0); + } + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } +`; + +export const SwitchText = styled.p` + font-family: 'Outfit', sans-serif; + font-size: 0.9rem; + color: ${C.muted}; + text-align: center; + margin-top: 1.5rem; +`; + +export const SwitchLink = styled.a` + color: ${C.accent}; + text-decoration: none; + font-weight: 500; + cursor: pointer; + transition: color 0.2s ease; + &:hover { + color: ${C.pink}; + text-decoration: underline; + } +`; + +export const ErrorMessage = styled.p` + font-family: 'Outfit', sans-serif; + font-size: 0.85rem; + color: ${C.error}; + margin-top: 0.5rem; + text-align: center; +`; diff --git a/app/components/styled/authLayout.js b/app/components/styled/authLayout.js new file mode 100644 index 0000000..4b94197 --- /dev/null +++ b/app/components/styled/authLayout.js @@ -0,0 +1,39 @@ +import styled from '@emotion/styled'; + +export const AuthPageWrapper = styled.div` + display: flex; + min-height: 100vh; + width: 100%; + background: #0d0d0d; + overflow: hidden; +`; + +export const VisualPanel = styled.div` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + background: linear-gradient(135deg, #0d0d0d 0%, #1a0a2e 50%, #0d1117 100%); + overflow: hidden; + + @media (max-width: 768px) { + display: none; + } +`; + +export const FormPanel = styled.div` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + background: #0d0d0d; + position: relative; + + @media (max-width: 768px) { + width: 100%; + } +`; diff --git a/app/components/styled/musicVisual.js b/app/components/styled/musicVisual.js new file mode 100644 index 0000000..ce97ca4 --- /dev/null +++ b/app/components/styled/musicVisual.js @@ -0,0 +1,130 @@ +import styled from '@emotion/styled'; +import { keyframes } from '@emotion/react'; + +const spin = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +`; + +const pulse = keyframes` + 0%, 100% { transform: scale(1); opacity: 0.15; } + 50% { transform: scale(1.08); opacity: 0.35; } +`; + +const equalize = keyframes` + 0%, 100% { height: 15%; } + 25% { height: 55%; } + 50% { height: 85%; } + 75% { height: 35%; } +`; + +const floatUp = keyframes` + 0% { transform: translateY(0) rotate(0deg); opacity: 0.7; } + 100% { transform: translateY(-280px) rotate(40deg); opacity: 0; } +`; + +export const VinylRecord = styled.div` + width: 260px; + height: 260px; + border-radius: 50%; + background: radial-gradient( + circle, + #1a1a2e 0%, + #0d0d0d 20%, + #2d1b4e 21%, + #0d0d0d 40%, + #2d1b4e 41%, + #0d0d0d 60%, + #2d1b4e 61%, + #0d0d0d 80%, + #1a1a2e 100% + ); + animation: ${spin} 8s linear infinite; + position: relative; + z-index: 2; + box-shadow: 0 0 60px rgba(255, 107, 53, 0.12); + cursor: pointer; + transition: box-shadow 0.4s ease; + &:hover { + animation-duration: 2s; + box-shadow: 0 0 90px rgba(232, 67, 147, 0.25); + } + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 55px; + height: 55px; + border-radius: 50%; + background: radial-gradient(circle, #ff6b35 0%, #e84393 100%); + box-shadow: 0 0 25px rgba(255, 107, 53, 0.5); + } +`; + +export const GlowRing = styled.div` + position: absolute; + width: ${(props) => props.size}px; + height: ${(props) => props.size}px; + border-radius: 50%; + border: 1px solid rgba(255, 107, 53, 0.08); + animation: ${pulse} ${(props) => props.duration}s ease-in-out infinite; + animation-delay: ${(props) => props.delay}s; +`; + +export const EqualizerWrapper = styled.div` + display: flex; + align-items: flex-end; + gap: 5px; + height: 100px; + position: absolute; + bottom: 70px; + z-index: 1; +`; + +export const EqualizerBar = styled.div` + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + animation: ${equalize} ${(props) => props.duration}s ease-in-out infinite; + animation-delay: ${(props) => props.delay}s; + opacity: 0.6; + transition: opacity 0.3s ease; + &:hover { + opacity: 1; + } +`; + +export const FloatingNote = styled.div` + position: absolute; + font-size: ${(props) => props.size}px; + color: rgba(255, 107, 53, 0.35); + animation: ${floatUp} ${(props) => props.duration}s ease-out infinite; + animation-delay: ${(props) => props.delay}s; + left: ${(props) => props.left}%; + bottom: ${(props) => props.bottom}%; + user-select: none; +`; + +export const BrandText = styled.h1` + font-family: 'Syne', sans-serif; + font-size: 3.2rem; + font-weight: 800; + letter-spacing: 0.3em; + color: #ffffff; + margin-bottom: 0.5rem; + z-index: 3; + text-shadow: 0 0 40px rgba(255, 107, 53, 0.25); +`; + +export const TaglineText = styled.p` + font-family: 'Outfit', sans-serif; + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.35); + letter-spacing: 0.25em; + z-index: 3; + margin-top: 0; + margin-bottom: 2.5rem; +`; diff --git a/app/containers/Auth/LoginForm.js b/app/containers/Auth/LoginForm.js new file mode 100644 index 0000000..7daf25a --- /dev/null +++ b/app/containers/Auth/LoginForm.js @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import Link from 'next/link'; +import PropTypes from 'prop-types'; +import { + FormCard, + FormTitle, + FormSubtitle, + InputWrapper, + InputLabel, + StyledInput, + SubmitButton, + SwitchText, + SwitchLink, + ErrorMessage +} from '@components/styled/authForm'; + +const LoginForm = ({ onSubmit, loading, error }) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const handleSubmit = (e) => { + e.preventDefault(); + onSubmit(email, password); + }; + + return ( + + Welcome back + Sign in to continue your musical journey +
+ + Email + setEmail(e.target.value)} + required + /> + + + Password + setPassword(e.target.value)} + required + /> + + {error && {error}} + + {loading ? 'Signing in...' : 'Sign In'} + +
+ + Don't have an account?{' '} + + Create one + + +
+ ); +}; + +LoginForm.propTypes = { + onSubmit: PropTypes.func.isRequired, + loading: PropTypes.bool, + error: PropTypes.string +}; + +export default LoginForm; diff --git a/app/containers/Auth/SignupForm.js b/app/containers/Auth/SignupForm.js new file mode 100644 index 0000000..b5d9188 --- /dev/null +++ b/app/containers/Auth/SignupForm.js @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import Link from 'next/link'; +import PropTypes from 'prop-types'; +import { + FormCard, + FormTitle, + FormSubtitle, + InputWrapper, + InputLabel, + StyledInput, + SubmitButton, + SwitchText, + SwitchLink, + ErrorMessage +} from '@components/styled/authForm'; + +const SignupForm = ({ onSubmit, loading, error }) => { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const handleSubmit = (e) => { + e.preventDefault(); + onSubmit(name, email, password); + }; + + return ( + + Join the beat + Create your account and start listening +
+ + Full Name + setName(e.target.value)} + required + /> + + + Email + setEmail(e.target.value)} + required + /> + + + Password + setPassword(e.target.value)} + required + /> + + {error && {error}} + + {loading ? 'Creating account...' : 'Create Account'} + +
+ + Already have an account?{' '} + + Sign in + + +
+ ); +}; + +SignupForm.propTypes = { + onSubmit: PropTypes.func.isRequired, + loading: PropTypes.bool, + error: PropTypes.string +}; + +export default SignupForm; diff --git a/app/containers/Auth/reducer.js b/app/containers/Auth/reducer.js new file mode 100644 index 0000000..711084b --- /dev/null +++ b/app/containers/Auth/reducer.js @@ -0,0 +1,48 @@ +import { PAYLOAD, startLoading, stopLoading, setError, setData } from '@app/utils/reducer'; +import produce from 'immer'; +import { createActions } from 'reduxsauce'; + +export const initialState = { + [PAYLOAD.DATA]: null, + [PAYLOAD.ERROR]: null, + [PAYLOAD.LOADING]: false +}; + +export const { Types: authTypes, Creators: authCreators } = createActions({ + requestLogin: ['email', 'password'], + successAuth: [PAYLOAD.DATA], + failureAuth: [PAYLOAD.ERROR], + requestSignup: ['name', 'email', 'password'], + clearAuth: null +}); + +const handleRequest = (draft) => { + startLoading(draft); + draft[PAYLOAD.ERROR] = null; +}; + +const handleSuccess = (draft, action) => { + stopLoading(draft); + setData(draft, action); +}; + +const handleFailure = (draft, action) => { + stopLoading(draft); + setError(draft, action); +}; + +const handlers = { + [authTypes.REQUEST_LOGIN]: handleRequest, + [authTypes.REQUEST_SIGNUP]: handleRequest, + [authTypes.SUCCESS_AUTH]: handleSuccess, + [authTypes.FAILURE_AUTH]: handleFailure, + [authTypes.CLEAR_AUTH]: () => initialState +}; + +export const authReducer = (state = initialState, action) => + produce(state, (draft) => { + const handler = handlers[action.type]; + return handler ? handler(draft, action) : state; + }); + +export default authReducer; diff --git a/app/containers/Auth/saga.js b/app/containers/Auth/saga.js new file mode 100644 index 0000000..382f35e --- /dev/null +++ b/app/containers/Auth/saga.js @@ -0,0 +1,30 @@ +import { call, put, takeLatest } from 'redux-saga/effects'; +import { loginUser, signupUser } from '@services/authApi'; +import { authTypes, authCreators } from './reducer'; + +const { successAuth, failureAuth } = authCreators; + +export function* handleLogin(action) { + const { email, password } = action; + const response = yield call(loginUser, { email, password }); + if (response.ok) { + yield put(successAuth(response.data)); + } else { + yield put(failureAuth(response.data)); + } +} + +export function* handleSignup(action) { + const { name, email, password } = action; + const response = yield call(signupUser, { name, email, password }); + if (response.ok) { + yield put(successAuth(response.data)); + } else { + yield put(failureAuth(response.data)); + } +} + +export default function* authSaga() { + yield takeLatest(authTypes.REQUEST_LOGIN, handleLogin); + yield takeLatest(authTypes.REQUEST_SIGNUP, handleSignup); +} diff --git a/app/containers/Auth/selectors.js b/app/containers/Auth/selectors.js new file mode 100644 index 0000000..18fef2b --- /dev/null +++ b/app/containers/Auth/selectors.js @@ -0,0 +1,13 @@ +import { PAYLOAD } from '@app/utils/reducer'; +import get from 'lodash/get'; +import { createSelector } from 'reselect'; +import { initialState } from './reducer'; + +const selectAuthDomain = (state) => state.auth || initialState; + +export const selectAuthData = () => createSelector(selectAuthDomain, (substate) => get(substate, PAYLOAD.DATA, null)); + +export const selectAuthError = () => createSelector(selectAuthDomain, (substate) => get(substate, PAYLOAD.ERROR, null)); + +export const selectAuthLoading = () => + createSelector(selectAuthDomain, (substate) => get(substate, PAYLOAD.LOADING, false)); diff --git a/app/containers/Auth/tests/LoginForm.test.js b/app/containers/Auth/tests/LoginForm.test.js new file mode 100644 index 0000000..e616174 --- /dev/null +++ b/app/containers/Auth/tests/LoginForm.test.js @@ -0,0 +1,54 @@ +import React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderProvider } from '@utils/testUtils' +import LoginForm from '../LoginForm' + +describe('', () => { + const mockSubmit = jest.fn() + const defaultProps = { onSubmit: mockSubmit, loading: false, error: null } + + beforeEach(() => { + mockSubmit.mockClear() + }) + + it('should render and match the snapshot', () => { + const { baseElement } = renderProvider() + expect(baseElement).toMatchSnapshot() + }) + + it('should render email and password inputs', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('login-email')).toBeTruthy() + expect(getByTestId('login-password')).toBeTruthy() + }) + + it('should call onSubmit with email and password', () => { + const { getByTestId } = renderProvider() + fireEvent.change(getByTestId('login-email'), { + target: { value: 'test@test.com' } + }) + fireEvent.change(getByTestId('login-password'), { + target: { value: 'pass123' } + }) + fireEvent.click(getByTestId('login-submit')) + expect(mockSubmit).toHaveBeenCalledWith('test@test.com', 'pass123') + }) + + it('should display error message when error prop is set', () => { + const props = { ...defaultProps, error: 'Invalid credentials' } + const { getByTestId } = renderProvider() + expect(getByTestId('login-error').textContent).toBe('Invalid credentials') + }) + + it('should disable submit button when loading', () => { + const props = { ...defaultProps, loading: true } + const { getByTestId } = renderProvider() + expect(getByTestId('login-submit')).toBeDisabled() + }) + + it('should show loading text when loading', () => { + const props = { ...defaultProps, loading: true } + const { getByTestId } = renderProvider() + expect(getByTestId('login-submit').textContent).toBe('Signing in...') + }) +}) diff --git a/app/containers/Auth/tests/SignupForm.test.js b/app/containers/Auth/tests/SignupForm.test.js new file mode 100644 index 0000000..1bb1948 --- /dev/null +++ b/app/containers/Auth/tests/SignupForm.test.js @@ -0,0 +1,62 @@ +import React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderProvider } from '@utils/testUtils' +import SignupForm from '../SignupForm' + +describe('', () => { + const mockSubmit = jest.fn() + const defaultProps = { onSubmit: mockSubmit, loading: false, error: null } + + beforeEach(() => { + mockSubmit.mockClear() + }) + + it('should render and match the snapshot', () => { + const { baseElement } = renderProvider() + expect(baseElement).toMatchSnapshot() + }) + + it('should render name, email, and password inputs', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('signup-name')).toBeTruthy() + expect(getByTestId('signup-email')).toBeTruthy() + expect(getByTestId('signup-password')).toBeTruthy() + }) + + it('should call onSubmit with name, email, and password', () => { + const { getByTestId } = renderProvider() + fireEvent.change(getByTestId('signup-name'), { + target: { value: 'Test User' } + }) + fireEvent.change(getByTestId('signup-email'), { + target: { value: 'test@test.com' } + }) + fireEvent.change(getByTestId('signup-password'), { + target: { value: 'pass123' } + }) + fireEvent.click(getByTestId('signup-submit')) + expect(mockSubmit).toHaveBeenCalledWith( + 'Test User', + 'test@test.com', + 'pass123' + ) + }) + + it('should display error message when error prop is set', () => { + const props = { ...defaultProps, error: 'Email already exists' } + const { getByTestId } = renderProvider() + expect(getByTestId('signup-error').textContent).toBe('Email already exists') + }) + + it('should disable submit button when loading', () => { + const props = { ...defaultProps, loading: true } + const { getByTestId } = renderProvider() + expect(getByTestId('signup-submit')).toBeDisabled() + }) + + it('should show loading text when loading', () => { + const props = { ...defaultProps, loading: true } + const { getByTestId } = renderProvider() + expect(getByTestId('signup-submit').textContent).toBe('Creating account...') + }) +}) diff --git a/app/containers/Auth/tests/__snapshots__/LoginForm.test.js.snap b/app/containers/Auth/tests/__snapshots__/LoginForm.test.js.snap new file mode 100644 index 0000000..252f31b --- /dev/null +++ b/app/containers/Auth/tests/__snapshots__/LoginForm.test.js.snap @@ -0,0 +1,222 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render and match the snapshot 1`] = ` +.emotion-0 { + width: 100%; + max-width: 420px; + padding: 3rem; + background: #161622; + border-radius: 16px; + border: 1px solid #2d2d44; +} + +.emotion-1 { + font-family: 'Syne',sans-serif; + font-size: 2rem; + font-weight: 700; + color: #ffffff; + margin-bottom: 0.5rem; +} + +.emotion-2 { + font-family: 'Outfit',sans-serif; + font-size: 0.95rem; + color: #6c7086; + margin-bottom: 2rem; +} + +.emotion-3 { + margin-bottom: 1.25rem; +} + +.emotion-4 { + font-family: 'Outfit',sans-serif; + font-size: 0.85rem; + color: #9ca0b0; + display: block; + margin-bottom: 0.5rem; +} + +.emotion-5 { + width: 100%; + padding: 0.85rem 1rem; + background: #1e1e30; + border: 1.5px solid #2d2d44; + border-radius: 10px; + color: #ffffff; + font-family: 'Outfit',sans-serif; + font-size: 0.95rem; + outline: none; + -webkit-transition: all 0.3s ease; + transition: all 0.3s ease; + box-sizing: border-box; +} + +.emotion-5::-webkit-input-placeholder { + color: #4a4a5a; +} + +.emotion-5::-moz-placeholder { + color: #4a4a5a; +} + +.emotion-5:-ms-input-placeholder { + color: #4a4a5a; +} + +.emotion-5::placeholder { + color: #4a4a5a; +} + +.emotion-5:focus { + border-color: #ff6b35; + box-shadow: 0 0 20px rgba(255, 107, 53, 0.15); +} + +.emotion-5:hover:not(:focus) { + border-color: #3d3d54; +} + +.emotion-9 { + width: 100%; + padding: 0.9rem; + background: linear-gradient(135deg, #ff6b35, #e84393); + border: none; + border-radius: 10px; + color: #ffffff; + font-family: 'Syne',sans-serif; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + -webkit-transition: all 0.3s ease; + transition: all 0.3s ease; + margin-top: 0.5rem; + letter-spacing: 0.05em; +} + +.emotion-9:hover { + -webkit-transform: translateY(-2px); + -moz-transform: translateY(-2px); + -ms-transform: translateY(-2px); + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(255, 107, 53, 0.3); +} + +.emotion-9:active { + -webkit-transform: translateY(0); + -moz-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); +} + +.emotion-9:disabled { + opacity: 0.6; + cursor: not-allowed; + -webkit-transform: none; + -moz-transform: none; + -ms-transform: none; + transform: none; +} + +.emotion-10 { + font-family: 'Outfit',sans-serif; + font-size: 0.9rem; + color: #6c7086; + text-align: center; + margin-top: 1.5rem; +} + +.emotion-11 { + color: #ff6b35; + -webkit-text-decoration: none; + text-decoration: none; + font-weight: 500; + cursor: pointer; + -webkit-transition: color 0.2s ease; + transition: color 0.2s ease; +} + +.emotion-11:hover { + color: #e84393; + -webkit-text-decoration: underline; + text-decoration: underline; +} + + +
+
+

+ Welcome back +

+

+ Sign in to continue your musical journey +

+
+
+ + +
+
+ + +
+ +
+

+ Don't have an account? + + + Create one + +

+
+
+ +`; diff --git a/app/containers/Auth/tests/__snapshots__/SignupForm.test.js.snap b/app/containers/Auth/tests/__snapshots__/SignupForm.test.js.snap new file mode 100644 index 0000000..e81890a --- /dev/null +++ b/app/containers/Auth/tests/__snapshots__/SignupForm.test.js.snap @@ -0,0 +1,241 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render and match the snapshot 1`] = ` +.emotion-0 { + width: 100%; + max-width: 420px; + padding: 3rem; + background: #161622; + border-radius: 16px; + border: 1px solid #2d2d44; +} + +.emotion-1 { + font-family: 'Syne',sans-serif; + font-size: 2rem; + font-weight: 700; + color: #ffffff; + margin-bottom: 0.5rem; +} + +.emotion-2 { + font-family: 'Outfit',sans-serif; + font-size: 0.95rem; + color: #6c7086; + margin-bottom: 2rem; +} + +.emotion-3 { + margin-bottom: 1.25rem; +} + +.emotion-4 { + font-family: 'Outfit',sans-serif; + font-size: 0.85rem; + color: #9ca0b0; + display: block; + margin-bottom: 0.5rem; +} + +.emotion-5 { + width: 100%; + padding: 0.85rem 1rem; + background: #1e1e30; + border: 1.5px solid #2d2d44; + border-radius: 10px; + color: #ffffff; + font-family: 'Outfit',sans-serif; + font-size: 0.95rem; + outline: none; + -webkit-transition: all 0.3s ease; + transition: all 0.3s ease; + box-sizing: border-box; +} + +.emotion-5::-webkit-input-placeholder { + color: #4a4a5a; +} + +.emotion-5::-moz-placeholder { + color: #4a4a5a; +} + +.emotion-5:-ms-input-placeholder { + color: #4a4a5a; +} + +.emotion-5::placeholder { + color: #4a4a5a; +} + +.emotion-5:focus { + border-color: #ff6b35; + box-shadow: 0 0 20px rgba(255, 107, 53, 0.15); +} + +.emotion-5:hover:not(:focus) { + border-color: #3d3d54; +} + +.emotion-12 { + width: 100%; + padding: 0.9rem; + background: linear-gradient(135deg, #ff6b35, #e84393); + border: none; + border-radius: 10px; + color: #ffffff; + font-family: 'Syne',sans-serif; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + -webkit-transition: all 0.3s ease; + transition: all 0.3s ease; + margin-top: 0.5rem; + letter-spacing: 0.05em; +} + +.emotion-12:hover { + -webkit-transform: translateY(-2px); + -moz-transform: translateY(-2px); + -ms-transform: translateY(-2px); + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(255, 107, 53, 0.3); +} + +.emotion-12:active { + -webkit-transform: translateY(0); + -moz-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); +} + +.emotion-12:disabled { + opacity: 0.6; + cursor: not-allowed; + -webkit-transform: none; + -moz-transform: none; + -ms-transform: none; + transform: none; +} + +.emotion-13 { + font-family: 'Outfit',sans-serif; + font-size: 0.9rem; + color: #6c7086; + text-align: center; + margin-top: 1.5rem; +} + +.emotion-14 { + color: #ff6b35; + -webkit-text-decoration: none; + text-decoration: none; + font-weight: 500; + cursor: pointer; + -webkit-transition: color 0.2s ease; + transition: color 0.2s ease; +} + +.emotion-14:hover { + color: #e84393; + -webkit-text-decoration: underline; + text-decoration: underline; +} + + +
+
+

+ Join the beat +

+

+ Create your account and start listening +

+
+
+ + +
+
+ + +
+
+ + +
+ +
+

+ Already have an account? + + + Sign in + +

+
+
+ +`; diff --git a/app/containers/Auth/tests/reducer.test.js b/app/containers/Auth/tests/reducer.test.js new file mode 100644 index 0000000..d6b5ce7 --- /dev/null +++ b/app/containers/Auth/tests/reducer.test.js @@ -0,0 +1,62 @@ +import { PAYLOAD } from '@app/utils/reducer' +import { authReducer, initialState, authTypes } from '../reducer' + +describe('Auth reducer tests', () => { + let state + beforeEach(() => { + state = initialState + }) + + it('should return the initial state', () => { + expect(authReducer(undefined, {})).toEqual(state) + }) + + it('should set loading to true when REQUEST_LOGIN is dispatched', () => { + const expectedResult = { ...state, loading: true, error: null } + expect( + authReducer(state, { + type: authTypes.REQUEST_LOGIN, + email: 'test@test.com', + password: 'password123' + }) + ).toEqual(expectedResult) + }) + + it('should set loading to true when REQUEST_SIGNUP is dispatched', () => { + const expectedResult = { ...state, loading: true, error: null } + expect( + authReducer(state, { + type: authTypes.REQUEST_SIGNUP, + name: 'Test User', + email: 'test@test.com', + password: 'password123' + }) + ).toEqual(expectedResult) + }) + + it('should set data when SUCCESS_AUTH is dispatched', () => { + const data = { token: 'abc123', user: { name: 'Test' } } + const expectedResult = { ...state, data, loading: false } + expect(authReducer(state, { type: authTypes.SUCCESS_AUTH, data })).toEqual( + expectedResult + ) + }) + + it('should set error when FAILURE_AUTH is dispatched', () => { + const expectedResult = { + ...state, + [PAYLOAD.ERROR]: 'something_went_wrong', + loading: false + } + expect(authReducer(state, { type: authTypes.FAILURE_AUTH })).toEqual( + expectedResult + ) + }) + + it('should reset state when CLEAR_AUTH is dispatched', () => { + const modifiedState = { ...state, data: { token: 'abc' }, loading: true } + expect(authReducer(modifiedState, { type: authTypes.CLEAR_AUTH })).toEqual( + initialState + ) + }) +}) diff --git a/app/containers/Auth/tests/saga.test.js b/app/containers/Auth/tests/saga.test.js new file mode 100644 index 0000000..1dff682 --- /dev/null +++ b/app/containers/Auth/tests/saga.test.js @@ -0,0 +1,75 @@ +import { takeLatest, call, put } from 'redux-saga/effects' +import { loginUser, signupUser } from '@services/authApi' +import { apiResponseGenerator } from '@utils/testUtils' +import authSaga, { handleLogin, handleSignup } from '../saga' +import { authTypes } from '../reducer' + +describe('Auth saga tests', () => { + const generator = authSaga() + + it('should watch for REQUEST_LOGIN action', () => { + expect(generator.next().value).toEqual( + takeLatest(authTypes.REQUEST_LOGIN, handleLogin) + ) + }) + + it('should watch for REQUEST_SIGNUP action', () => { + expect(generator.next().value).toEqual( + takeLatest(authTypes.REQUEST_SIGNUP, handleSignup) + ) + }) + + describe('handleLogin', () => { + const action = { email: 'test@test.com', password: 'pass123' } + + it('should dispatch SUCCESS_AUTH on successful login', () => { + const gen = handleLogin(action) + const res = gen.next().value + expect(res).toEqual( + call(loginUser, { email: action.email, password: action.password }) + ) + const successData = { token: 'abc123' } + expect(gen.next(apiResponseGenerator(true, successData)).value).toEqual( + put({ type: authTypes.SUCCESS_AUTH, data: successData }) + ) + }) + + it('should dispatch FAILURE_AUTH on failed login', () => { + const gen = handleLogin(action) + gen.next() + const errorData = { message: 'Invalid credentials' } + expect(gen.next(apiResponseGenerator(false, errorData)).value).toEqual( + put({ type: authTypes.FAILURE_AUTH, error: errorData }) + ) + }) + }) + + describe('handleSignup', () => { + const action = { name: 'Test', email: 'test@test.com', password: 'pass123' } + + it('should dispatch SUCCESS_AUTH on successful signup', () => { + const gen = handleSignup(action) + const res = gen.next().value + expect(res).toEqual( + call(signupUser, { + name: action.name, + email: action.email, + password: action.password + }) + ) + const successData = { token: 'abc123' } + expect(gen.next(apiResponseGenerator(true, successData)).value).toEqual( + put({ type: authTypes.SUCCESS_AUTH, data: successData }) + ) + }) + + it('should dispatch FAILURE_AUTH on failed signup', () => { + const gen = handleSignup(action) + gen.next() + const errorData = { message: 'Email already exists' } + expect(gen.next(apiResponseGenerator(false, errorData)).value).toEqual( + put({ type: authTypes.FAILURE_AUTH, error: errorData }) + ) + }) + }) +}) diff --git a/app/containers/Auth/tests/selectors.test.js b/app/containers/Auth/tests/selectors.test.js new file mode 100644 index 0000000..b2d095d --- /dev/null +++ b/app/containers/Auth/tests/selectors.test.js @@ -0,0 +1,46 @@ +import { + selectAuthData, + selectAuthError, + selectAuthLoading +} from '../selectors' + +describe('Auth selector tests', () => { + let mockedState + let authData + let authError + + beforeEach(() => { + authData = { token: 'abc123', user: { name: 'Test' } } + authError = 'Invalid credentials' + + mockedState = { + auth: { + data: authData, + error: authError, + loading: true + } + } + }) + + it('should select auth data', () => { + const dataSelector = selectAuthData() + expect(dataSelector(mockedState)).toEqual(authData) + }) + + it('should select auth error', () => { + const errorSelector = selectAuthError() + expect(errorSelector(mockedState)).toEqual(authError) + }) + + it('should select auth loading', () => { + const loadingSelector = selectAuthLoading() + expect(loadingSelector(mockedState)).toEqual(true) + }) + + it('should return defaults when auth state is empty', () => { + const emptyState = {} + expect(selectAuthData()(emptyState)).toBeNull() + expect(selectAuthError()(emptyState)).toBeNull() + expect(selectAuthLoading()(emptyState)).toBe(false) + }) +}) diff --git a/app/reducers.js b/app/reducers.js index eb74141..6b427d1 100644 --- a/app/reducers.js +++ b/app/reducers.js @@ -7,6 +7,7 @@ import { combineReducers } from 'redux'; import repos from './containers/Repos/reducer'; import info from './containers/Info/reducer'; +import auth from './containers/Auth/reducer'; enableAllPlugins(); @@ -17,7 +18,8 @@ export default function createReducer(injectedReducer = {}) { const rootReducer = combineReducers({ ...injectedReducer, repos, - info + info, + auth }); return rootReducer; diff --git a/app/services/authApi.js b/app/services/authApi.js new file mode 100644 index 0000000..dee2d61 --- /dev/null +++ b/app/services/authApi.js @@ -0,0 +1,7 @@ +import { generateApiClient } from '@utils/apiUtils'; + +const authApi = generateApiClient('auth'); + +export const loginUser = (credentials) => authApi.post('/login', credentials); + +export const signupUser = (userData) => authApi.post('/signup', userData); diff --git a/app/utils/apiUtils.js b/app/utils/apiUtils.js index 34b6722..68c4027 100644 --- a/app/utils/apiUtils.js +++ b/app/utils/apiUtils.js @@ -5,7 +5,8 @@ import { mapKeysDeep } from './index'; const apiClients = { github: null, - default: null + default: null, + auth: null }; export const getApiClient = (type = 'github') => apiClients[type]; @@ -14,6 +15,9 @@ export const generateApiClient = (type = 'github') => { case 'github': apiClients[type] = createApiClientWithTransForm(process.env.NEXT_PUBLIC_GITHUB_URL); return apiClients[type]; + case 'auth': + apiClients[type] = createApiClientWithTransForm('http://localhost:9000'); + return apiClients[type]; default: apiClients.default = createApiClientWithTransForm(process.env.NEXT_PUBLIC_GITHUB_URL); return apiClients.default; diff --git a/pages/_document.js b/pages/_document.js index 282aeff..c1d7ad9 100644 --- a/pages/_document.js +++ b/pages/_document.js @@ -8,7 +8,14 @@ const MyDocument = ({ localeDataScript, locale, styles }) => { return ( - + + + + +