Skip to content

Commit c26893b

Browse files
No freaking way - after hours of work, the infinite carousel finally works.
It's probably a skill issue on my part, but I felt this was wayy more difficult than it appears.
1 parent 42fb520 commit c26893b

31 files changed

+450
-492
lines changed

package-lock.json

Lines changed: 64 additions & 410 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@
1717
"react": "^18.3.1",
1818
"react-dom": "^18.3.1",
1919
"react-intersection-observer": "^9.13.1",
20-
"react-router-dom": "^6.27.0",
21-
"react-select": "^5.8.2"
20+
"react-router-dom": "^6.27.0"
2221
},
2322
"devDependencies": {
2423
"@eslint/js": "^9.13.0",

src/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ import AdoptionGalleryPage from './pages/AdoptionGalleryPage'
66
import PetDetailPage from './pages/PetDetailPage'
77
import CheckoutPage from './pages/CheckoutPage'
88
import ReceiptPage from './pages/ReceiptPage'
9+
import MyAccountPage from './pages/MyAccountPage'
10+
import PetReleasePage from './pages/PetReleasePage'
911

1012
const browserRouter = createBrowserRouter([
1113
{ path: ROUTE_URL.HOME, element: <HomePage /> },
1214
{ path: ROUTE_URL.AUTH, element: <AuthPage /> },
1315
{ path: ROUTE_URL.GALLERY, element: <AdoptionGalleryPage /> },
1416
{ path: ROUTE_URL.PET_DETAIL, element: <PetDetailPage /> },
1517
{ path: ROUTE_URL.CHECKOUT, element: <CheckoutPage /> },
16-
{ path: ROUTE_URL.RECEIPT, element: <ReceiptPage /> }
18+
{ path: ROUTE_URL.RECEIPT, element: <ReceiptPage /> },
19+
{ path: ROUTE_URL.MY_ACCOUNT, element: <MyAccountPage /> },
20+
{ path: ROUTE_URL.PET_RELEASE, element: <PetReleasePage/> }
1721
])
1822

1923
const App: React.FC = () => {

src/assets/SVG/account_circle.svg

Lines changed: 1 addition & 0 deletions
Loading

src/assets/SVG/arrow_right.svg

Lines changed: 1 addition & 0 deletions
Loading

src/assets/SVG/logout.svg

Lines changed: 1 addition & 0 deletions
Loading
923 KB
Loading
1.39 MB
Loading
1.84 MB
Loading
1.13 MB
Loading
2.1 MB
Loading
1.64 MB
Loading
1.72 MB
Loading
784 KB
Loading
2.1 MB
Loading
4.37 MB
Loading
725 KB
Loading
1.04 MB
Loading
357 KB
Loading
657 KB
Loading

src/components/AccountDropdown.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import React, { useContext, useEffect, useRef, useState } from 'react'
2+
import { AuthContext } from '../common/AuthContext'
3+
4+
import account_svg from '../assets/SVG/account_circle.svg'
5+
import logout_svg from '../assets/SVG/logout.svg'
6+
import arrow_right_svg from '../assets/SVG/arrow_right.svg'
7+
import { firebaseAuth } from '../others/FirebaseConfig'
8+
import { signOut } from 'firebase/auth'
9+
import { useNavigate } from 'react-router-dom'
10+
import { ROUTE_URL } from '../others/Globals'
11+
12+
const AccountDropdown: React.FC = () => {
13+
const [m_isOpen, setIsOpen] = useState<boolean>(false)
14+
const m_dropdownRef = useRef<HTMLDivElement>(null)
15+
const m_authCtx = useContext(AuthContext)
16+
const m_navTo = useNavigate()
17+
18+
const windowClickListener = (evt: MouseEvent): void => {
19+
if (!m_isOpen || !m_dropdownRef.current ||
20+
m_dropdownRef.current.contains(evt.target as Node)
21+
) { return }
22+
setIsOpen(false)
23+
}
24+
25+
useEffect(() => {
26+
document.addEventListener('mousedown', windowClickListener)
27+
return () => document.removeEventListener('mousedown', windowClickListener)
28+
}, [m_isOpen])
29+
30+
const logoutClickHandler = async (): Promise<void> => {
31+
await signOut(firebaseAuth)
32+
}
33+
34+
return (
35+
<div ref={m_dropdownRef} className='relative cursor-pointer'>
36+
37+
{/* --- HEADER BUTTON --- */}
38+
<div onClick={() => setIsOpen(isOpen => !isOpen)} className='flex justify-start items-center'>
39+
<img className='w-8 h-8 mr-margin-xs' src={account_svg} alt="" />
40+
<button className='mr-text flex'>
41+
<span className='mr-text-xxs text-xl'>{
42+
m_authCtx?.firebaseUser?.displayName ?
43+
"Hi, " + m_authCtx.firebaseUser.displayName : "User"
44+
}</span>
45+
</button>
46+
<img className='rotate-90 w-7 h-7' src={arrow_right_svg} alt="" />
47+
</div>
48+
49+
{/* --- ABSOLUTELY POSITIONED DROPDOWN ---- */}
50+
<div
51+
style={{ display: m_isOpen ? "block" : "none" }}
52+
className='
53+
absolute z-10 bg-accent-700 rounded-md shadow-xl right-0 mt-margin-m
54+
flex flex-col justify-start items-center w-52
55+
'>
56+
<button onClick={() => m_navTo(ROUTE_URL.MY_ACCOUNT)} className='
57+
w-full flex justify-start pl-margin-l items-center py-2
58+
hover:bg-accent-400 transition-colors duration-75
59+
'>
60+
<img className='w-6 h-6 mr-margin-xs' src={account_svg} alt="" />
61+
<span>My account</span>
62+
</button>
63+
<button onClick={logoutClickHandler} className='
64+
w-full flex justify-start pl-margin-l items-center py-2
65+
hover:bg-accent-400 transition-colors duration-75
66+
'>
67+
<img className='w-6 h-6 mr-margin-xs' src={logout_svg} alt="" />
68+
<span>Logout</span>
69+
</button>
70+
</div>
71+
</div>
72+
)
73+
}
74+
75+
export default AccountDropdown

src/components/Dropdown.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { Dispatch, SetStateAction, useState } from 'react'
1+
import React, { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'
22

33
export interface DropdownOption {
44
optionName: string
@@ -14,6 +14,19 @@ interface DropdownProps {
1414

1515
const Dropdown: React.FC<DropdownProps> = props => {
1616
const [m_isOpen, setIsOpen] = useState<boolean>(false)
17+
const dropdownRef = useRef<HTMLDivElement>(null)
18+
19+
const windowClickListener = (evt: MouseEvent): void => {
20+
if (!m_isOpen || !dropdownRef.current ||
21+
dropdownRef.current.contains(evt.target as Node)
22+
) { return }
23+
setIsOpen(false)
24+
}
25+
26+
useEffect(() => {
27+
document.addEventListener('mousedown', windowClickListener)
28+
return () => document.removeEventListener('mousedown', windowClickListener)
29+
}, [m_isOpen])
1730

1831
const onChangeHandler = (evt: React.ChangeEvent<HTMLInputElement>, optionName: string): void => {
1932
props.setOptions(oldOptions => {
@@ -25,7 +38,7 @@ const Dropdown: React.FC<DropdownProps> = props => {
2538
}
2639

2740
return (
28-
<div className='relative'>
41+
<div ref={dropdownRef} className='relative'>
2942
<button
3043
onClick={() => setIsOpen(isOpen => !isOpen)}
3144
className='

src/components/Navbar.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import React, { useEffect, useRef } from 'react'
1+
import React, { useContext, useEffect, useRef } from 'react'
22
import { Link } from 'react-router-dom'
33
import { ROUTE_URL } from '../others/Globals'
44
import company_logo from '../assets/images/company_logo.png'
5+
import { AuthContext } from '../common/AuthContext'
6+
import AccountDropdown from './AccountDropdown'
57

68
interface NavbarProps {
79
useSticky?: boolean
@@ -10,6 +12,7 @@ interface NavbarProps {
1012

1113
const Navbar: React.FC<NavbarProps> = props => {
1214
const headerRef = useRef<HTMLElement>(null)
15+
const authCtx = useContext(AuthContext)
1316

1417
useEffect(() => {
1518
const HEADER_RGB = '81, 22, 96'
@@ -59,15 +62,18 @@ const Navbar: React.FC<NavbarProps> = props => {
5962
hidden md:flex justify-center items-center
6063
gap-11 lg:gap-20
6164
'>
62-
<Link className='hover:text-accent-600 transition-colors' to={ROUTE_URL.HOME}>Home</Link>
65+
<Link className='hover:text-accent-600 transition-colors' to={ROUTE_URL.GALLERY}>Adoption</Link>
6366
<Link className='hover:text-accent-600 transition-colors' to={ROUTE_URL.ABOUT}>About</Link>
6467
<Link className='hover:text-accent-600 transition-colors' to={ROUTE_URL.CONTACT_US}>Contact Us</Link>
65-
<Link className='hover:text-accent-600 transition-colors' to={ROUTE_URL.GALLERY}>Adoption</Link>
6668
</nav>
67-
<Link className='
68-
hidden justify-center items-center bg-primary-500 px-7 h-10 rounded-lg
69-
md:flex hover:bg-primary-600 transition-colors
70-
' to={ROUTE_URL.AUTH}>Login</Link>
69+
{
70+
(authCtx && authCtx.firebaseUser) ?
71+
<AccountDropdown /> :
72+
<Link className='
73+
hidden justify-center items-center bg-primary-500 px-7 h-10 rounded-lg
74+
md:flex hover:bg-primary-600 transition-colors' to={ROUTE_URL.AUTH}
75+
>Login</Link>
76+
}
7177
</header>
7278
)
7379
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import React from 'react'
2+
3+
const PetReleaseSection: React.FC = () => {
4+
return (
5+
<div>PetReleaseSection</div>
6+
)
7+
}
8+
9+
export default PetReleaseSection

src/components/sections/TestimonialsSection.tsx

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,165 @@
1-
import React from 'react'
1+
import React, { useEffect, useRef, useState } from 'react'
2+
import rabbit_1 from '../../assets/images/testimonials/rabbit_01.jpg'
3+
import rabbit_2 from '../../assets/images/testimonials/rabbit_02.jpg'
4+
import cat_1 from '../../assets/images/testimonials/cat_01.jpg'
5+
import cat_2 from '../../assets/images/testimonials/cat_02.jpg'
6+
import cat_3 from '../../assets/images/testimonials/cat_03.jpg'
7+
import cat_4 from '../../assets/images/testimonials/cat_04.jpg'
8+
import dog_1 from '../../assets/images/testimonials/dog_01.jpg'
9+
import dog_2 from '../../assets/images/testimonials/dog_02.jpg'
10+
import dog_3 from '../../assets/images/testimonials/dog_03.jpg'
11+
import dog_4 from '../../assets/images/testimonials/dog_04.jpg'
12+
import dog_5 from '../../assets/images/testimonials/dog_05.jpg'
13+
import dog_6 from '../../assets/images/testimonials/dog_07.jpg'
14+
import dog_7 from '../../assets/images/testimonials/dog_08.jpg'
15+
import { keyframes } from 'framer-motion'
16+
17+
interface TestimonialData {
18+
imgURL: string
19+
reviewer?: string
20+
content?: string
21+
}
22+
23+
interface TestimonialCarouselProps {
24+
reverseScrollDir?: boolean
25+
testimonialData: TestimonialData[]
26+
}
27+
28+
const testimonialDataTop: TestimonialData[] = [
29+
{ imgURL: cat_1 },
30+
{ imgURL: dog_1 },
31+
{ imgURL: dog_2 },
32+
// { imgURL: dog_3 },
33+
// { imgURL: cat_2 },
34+
// { imgURL: rabbit_1 },
35+
]
36+
37+
const testimonialDataBottom: TestimonialData[] = [
38+
{ imgURL: dog_4 },
39+
// { imgURL: cat_3 },
40+
// { imgURL: dog_5 },
41+
// { imgURL: dog_6 },
42+
// { imgURL: cat_4 },
43+
// { imgURL: rabbit_2 },
44+
// { imgURL: dog_7 },
45+
]
46+
47+
interface CarouselItemProps {
48+
widthRem: number
49+
heightRem: number
50+
offset: number
51+
testimonialData: TestimonialData
52+
}
53+
54+
const CarouselItem: React.FC<CarouselItemProps> = props => {
55+
return (
56+
<div
57+
style={{
58+
width: props.widthRem + "rem",
59+
height: props.heightRem + "rem",
60+
transform: `translateX(${props.offset}rem)`
61+
}}
62+
className='absolute rounded-xl outline outline-2 outline-red-400'
63+
>
64+
<div
65+
style={{ backgroundImage: `url(${props.testimonialData.imgURL})` }}
66+
className='w-full h-full bg-cover rounded-xl'
67+
></div>
68+
</div>
69+
)
70+
}
71+
72+
const TestimonialCarousel: React.FC<TestimonialCarouselProps> = props => {
73+
const WIDTH_REM = 18
74+
const HEIGHT_REM = 13
75+
76+
const [m_carouselOffsets, setCarouselOffets] = useState<number[]>([])
77+
const [m_parentWidthRem, setParentWidthRem] = useState<number>(0)
78+
const m_animHandle = useRef<number>(-1)
79+
const m_divRef = useRef<HTMLDivElement>(null)
80+
81+
// Listen for changes to the "testimonial data" passed via props
82+
// The nth carousel item can find out it's corresponding translateX via m_carouselOffset[n]
83+
//
84+
// Value of m_carouseOffset will be updated via the requestAnimationFrame in a useEffect below
85+
useEffect(() => {
86+
setCarouselOffets(
87+
props.testimonialData.map((_, idx) => {
88+
return WIDTH_REM * idx
89+
})
90+
)
91+
if (m_animHandle.current) {
92+
cancelAnimationFrame(m_animHandle.current)
93+
}
94+
}, [props.testimonialData])
95+
96+
// Save the with of the parent container containing the carousel
97+
// So we know the threshold before having to wrap an element
98+
useEffect(() => {
99+
if (m_divRef.current) {
100+
const remSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
101+
setParentWidthRem(m_divRef.current.offsetWidth / remSize)
102+
}
103+
}, [m_divRef.current])
104+
105+
useEffect(() => {
106+
const SCROLL_SPEED = 5
107+
108+
let prevTime = 0
109+
const onTick = (timeElapsed: number): void => {
110+
const deltaTime = (timeElapsed - prevTime) / 1000;
111+
prevTime = timeElapsed;
112+
113+
setCarouselOffets(prevOffsets => {
114+
return prevOffsets.map(offset => {
115+
const newOffset = offset +
116+
(props.reverseScrollDir ?
117+
SCROLL_SPEED * deltaTime : -SCROLL_SPEED * deltaTime)
118+
if (props.reverseScrollDir) {
119+
if (newOffset > m_parentWidthRem) {
120+
return newOffset - m_parentWidthRem - WIDTH_REM
121+
}
122+
}
123+
else {
124+
if (newOffset < -WIDTH_REM) {
125+
return newOffset + m_parentWidthRem + WIDTH_REM
126+
}
127+
}
128+
return newOffset
129+
})
130+
})
131+
m_animHandle.current = window.requestAnimationFrame(onTick)
132+
}
133+
m_animHandle.current = window.requestAnimationFrame(onTick)
134+
return () => {
135+
if (m_animHandle.current) {
136+
cancelAnimationFrame(m_animHandle.current)
137+
}
138+
}
139+
}, [props.testimonialData.length, m_parentWidthRem])
140+
141+
return (
142+
<div
143+
style={{ width: "96%", height: HEIGHT_REM + "rem" }}
144+
ref={m_divRef} className='relative overflow-hidden outline outline-2 outline-red-700'>
145+
{props.testimonialData.map((data, idx) => {
146+
return (
147+
<CarouselItem
148+
key={idx} offset={m_carouselOffsets[idx]}
149+
widthRem={WIDTH_REM} heightRem={HEIGHT_REM}
150+
testimonialData={data}
151+
/>
152+
)
153+
})}
154+
</div>
155+
)
156+
}
2157

3158
const TestimonialsSection: React.FC = () => {
4159
return (
5-
<section className='min-h-96 bg-gray-600'>
6-
Testimonials
160+
<section className='min-h-96 bg-gray-600 py-margin-xl flex flex-col items-center gap-6'>
161+
<TestimonialCarousel testimonialData={testimonialDataTop} />
162+
<TestimonialCarousel reverseScrollDir testimonialData={testimonialDataBottom} />
7163
</section>
8164
)
9165
}

src/index.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@
8686

8787
html {
8888
scroll-behavior: smooth;
89+
90+
---testimonial-card-width: 20rem;
8991
}
9092

9193
html,
@@ -107,4 +109,13 @@ textarea {
107109
img {
108110
display: block;
109111
width: 100%;
112+
}
113+
114+
@keyframes scroll_anim {
115+
0% {
116+
left: 100%;
117+
}
118+
100% {
119+
left: calc(var(---testimonial-card-width) * -1);
120+
}
110121
}

0 commit comments

Comments
 (0)