Repository: OpenBeta/open-tacos

Issue:
Tag photo overlay button not working.
- Click on a photo in latest photos. (or go to a climb or area)
- On the photo in lower left click the Tag icon
- See that the page flashes and nothing happens

<img width="228" height="164" alt="Image" src="https://github.com/user-attachments/assets/5f0f4f80-14ee-4832-934d-2cb9ac03b6be" />

![Image](https://github.com/user-attachments/assets/2ffdf2fd-b512-4f03-a530-7f6be27ebd9e)


Hints from issue comments:
@musoke: This has been an issue for a while, but I only notice when I'm too busy
uploading photos to submit an issue.

On Wed, Jul 30, 2025, 15:50 Michael Schennum ***@***.***>
wrote:

> *mikeschen* created an issue (OpenBeta/open-tacos#1363)
> <https://github.com/OpenBeta/open-tacos/issues/1363>
>
>    - Click on a photo in latest photos. (or go to a climb or area)
>    - On the photo in lower left click the Tag icon
>    - See that the page flashes and nothing happens
>
> Screenshot.2025-07-30.at.12.47.48.PM.png (view on web)
> <https://github.com/user-attachments/assets/5f0f4f80-14ee-4832-934d-2cb9ac03b6be>
>
> tag.gif (view on web)
> <https://github.com/user-attachments/assets/2ffdf2fd-b512-4f03-a530-7f6be27ebd9e>
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/OpenBeta/open-tacos/issues/1363>, or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AD7ET7ELPAIQ7IQ5H3265733LEO2BAVCNFSM6AAAAACCX46UBCVHI2DSMVQWIX3LMV43ASLTON2WKOZTGI3TQMRWGY4TSMY>
> .
> You are receiving this because you are subscribed to this thread.Message
> ID: ***@***.***>
>
@mikeschen: This just links to the tag. ~~Which is just going to the same page. It makes more sense to add a Tag I think.~~
I may remove since we can not tag other peoples photos. Or better show all the tags for that photo....@mikeschen: Completed. Shows tags now.

Solution patch:
diff --git a/src/app/(default)/components/LandingHero.tsx b/src/app/(default)/components/LandingHero.tsx
index 9c6b0c8..e22bce0 100644
--- a/src/app/(default)/components/LandingHero.tsx
+++ b/src/app/(default)/components/LandingHero.tsx
@@ -1,20 +1,52 @@
+'use client'
+import { useSession } from 'next-auth/react'
+import Link from 'next/link'
 import { AppAlert } from '@/components/broadcast/AppAlert'
 import { SignupButton } from './DesktopHeader'
 
+const STORAGE_KEY_JOIN_US = 'suppress-join-us-banner'
+const STORAGE_KEY_TEST_AREA = 'suppress-test-area-banner'
+
+const TEST_AREA_ID = process.env.NEXT_PUBLIC_TEST_AREA_ID ?? '18c5dd5c-8186-50b6-8a60-ae2948c548d1'
+const TEST_AREA_URL = `/area/${TEST_AREA_ID}/test-area`
+
 export const LandingHero: React.FC = () => {
-  return (
-    <section className='mt-4'>
+  const { status } = useSession()
+
+  if (status === 'authenticated') {
+    return (
       <AppAlert
+        cookieStorageKey={STORAGE_KEY_TEST_AREA}
         message={
           <>
-            <h1 className='text-xl tracking-tighter font-bold'>Share your climbing route knowledge!</h1>
+            <h1 className='text-xl tracking-tighter font-bold'>Try out adding a route!</h1>
             <div className='font-medium text-neutral/80'>
-              <p>Join us to help improve this comprehensive <br /> climbing resource for the community.</p>
+              <p>
+                You're in the test area — perfect for exploring how everything works.
+                <br />Add a route and see it in action!
+              </p>
             </div>
-            <SignupButton />
+            <Link href={TEST_AREA_URL} className='btn btn-accent border-b-2 border-b-neutral'>
+              Test Area
+            </Link>
           </>
         }
       />
-    </section>
+    )
+  }
+
+  return (
+    <AppAlert
+      cookieStorageKey={STORAGE_KEY_JOIN_US}
+      message={
+        <>
+          <h1 className='text-xl tracking-tighter font-bold'>Share your climbing route knowledge!</h1>
+          <div className='font-medium text-neutral/80'>
+            <p>Join us to help improve this comprehensive <br /> climbing resource for the community.</p>
+          </div>
+          <SignupButton />
+        </>
+      }
+    />
   )
 }
diff --git a/src/app/(default)/components/RecentTags.tsx b/src/app/(default)/components/RecentTags.tsx
index bd03015..8725afc 100644
--- a/src/app/(default)/components/RecentTags.tsx
+++ b/src/app/(default)/components/RecentTags.tsx
@@ -1,17 +1,24 @@
 import { RecentImageCard } from '@/app/(default)/components/RecentMediaCard'
 import { SectionContainer } from './ui/SectionContainer'
 import { getMediaForFeedSC } from '@/js/graphql/gql/serverApi'
+import { MediaByUsers, EntityTag } from '@/js/types'
 
 /**
  * Horizontal gallery of recent images with tags
  */
 export const RecentTags: React.FC = async () => {
-  const recentTagsByUsers = await getMediaForFeedSC(20, 4)
+  let recentTagsByUsers: MediaByUsers[] = []
+  try {
+    recentTagsByUsers = await getMediaForFeedSC(20, 4)
+  } catch (error) {
+    console.error('Failed to fetch recent tags during build:', error)
+    // Continue with empty data if API is down
+  }
   const testAreaIds = Array.from(new Set((process.env.NEXT_PUBLIC_TEST_AREA_IDS ?? '').split(',')))
   const mediaWithTags = recentTagsByUsers.flatMap(entry => entry.mediaWithTags)
 
   const recentMediaWithTags = mediaWithTags.filter(tag => {
-    return tag.entityTags.some(entityTag => {
+    return tag.entityTags.some((entityTag: EntityTag) => {
       return testAreaIds.every(testId => {
         const regex = new RegExp(testId, 'g')
         return !regex.test(entityTag.ancestors)
diff --git a/src/app/(default)/page.tsx b/src/app/(default)/page.tsx
index 9ce4512..2e7351c 100644
--- a/src/app/(default)/page.tsx
+++ b/src/app/(default)/page.tsx
@@ -8,6 +8,7 @@ import { USAToC } from './components/USAToC'
 import { InternationalToC } from './components/InternationalToC'
 import { Volunteers } from './components/Volunteers'
 import { RecentContributionsMap } from './components/recent/RecentContributionsMap'
+import { ChangesetType } from '@/js/types'
 
 export const revalidate = 3600 // 1 hour
 
@@ -15,7 +16,13 @@ export const revalidate = 3600 // 1 hour
  * Root home page
  */
 export default async function Home (): Promise<any> {
-  const history = await getChangeHistoryServerSide()
+  let history: ChangesetType[] = []
+  try {
+    history = await getChangeHistoryServerSide()
+  } catch (error) {
+    console.error('Failed to fetch change history during build:', error)
+    // Continue with empty history if API is down
+  }
   return (
     <>
       <div className='default-page-margins flex flex-col justify-center w-fit'>
diff --git a/src/app/api/mobile/login/route.ts b/src/app/api/mobile/login/route.ts
index ba48c3e..db1cec8 100644
--- a/src/app/api/mobile/login/route.ts
+++ b/src/app/api/mobile/login/route.ts
@@ -18,7 +18,8 @@ async function postHandler (request: NextRequest): Promise<NextResponse> {
       console.error('Empty username/password!')
       throw new Error('Invalid payload')
     }
-  } catch (error) {
+  } catch (err) {
+    console.error('POST /login error:', err)
     return NextResponse.json({ error: 'Unexpected error' }, { status: 400 })
   }
 
@@ -27,7 +28,7 @@ async function postHandler (request: NextRequest): Promise<NextResponse> {
     response = await auth0Client.oauth.passwordGrant({
       username,
       password,
-      scope: 'openid profile email offline_access',
+      scope: 'offline_access access_token_authz openid email profile read:current_user create:current_user_metadata update:current_user_metadata read:stats update:area_attrs',
       audience: 'https://api.openbeta.io',
       realm: 'Username-Password-Authentication'
     })
diff --git a/src/app/api/mobile/refreshToken/route.ts b/src/app/api/mobile/refreshToken/route.ts
index b516d3b..3f531cd 100644
--- a/src/app/api/mobile/refreshToken/route.ts
+++ b/src/app/api/mobile/refreshToken/route.ts
@@ -28,6 +28,7 @@ async function postHandler (request: NextRequest): Promise<any> {
   try {
     response = await auth0Client.oauth.refreshTokenGrant({
       refresh_token: refreshToken,
+      scope: 'offline_access access_token_authz openid email profile read:current_user create:current_user_metadata update:current_user_metadata read:stats update:area_attrs',
       audience: 'https://api.openbeta.io'
     })
 
diff --git a/src/components/broadcast/AppAlert.tsx b/src/components/broadcast/AppAlert.tsx
index 099b51f..ed4580f 100644
--- a/src/components/broadcast/AppAlert.tsx
+++ b/src/components/broadcast/AppAlert.tsx
@@ -1,43 +1,42 @@
 'use client'
 import Cookies from 'js-cookie'
 import { useState, useEffect } from 'react'
-import { useSession } from 'next-auth/react'
 import { SuppressButton } from './SuppressButton'
 
-const STORAGE_KEY = 'suppress-main-banner'
-
 export interface AppAlertProps {
   message: JSX.Element
+  cookieStorageKey: string
 }
 
 /**
  * Main alert to be displayed under the nav bar.  Users can snooze the alert.
  * @param message alert content
  */
-export const AppAlert: React.FC<AppAlertProps> = ({ message }) => {
-  const { status } = useSession()
+export const AppAlert: React.FC<AppAlertProps> = ({
+  message,
+  cookieStorageKey
+}) => {
   const [showAlert, setShowAlert] = useState(false)
+
   useEffect(() => {
-    const suppressed = Cookies.get(STORAGE_KEY)
+    const suppressed = Cookies.get(cookieStorageKey)
+
     setShowAlert(suppressed == null)
-  }, [])
+  }, [cookieStorageKey])
 
-  // Hide alert if user is logged in
-  return showAlert && status !== 'authenticated'
-    ? (
+  return (
+    showAlert && (
       <div className='z-40 w-fit alert alert-info flex flex-wrap justify-center xl:p-4 gap-4'>
-        <div className='flex flex-col gap-2 items-start'>
-          {message}
-        </div>
+        <div className='flex flex-col gap-2 items-start'>{message}</div>
         <div className='inline-flex w-[210px] grow-1'>
           <SuppressButton
             onClick={() => {
               setShowAlert(false)
-              Cookies.set(STORAGE_KEY, '1', { strict: true, expires: 30 })
+              Cookies.set(cookieStorageKey, '1', { strict: true, expires: 30 })
             }}
           />
         </div>
       </div>
-      )
-    : null
+    )
+  )
 }
diff --git a/src/components/media/PhotoFooter.tsx b/src/components/media/PhotoFooter.tsx
index 87bb6b2..44e199f 100644
--- a/src/components/media/PhotoFooter.tsx
+++ b/src/components/media/PhotoFooter.tsx
@@ -1,4 +1,5 @@
 import Link from 'next/link'
+import { useState } from 'react'
 import { Transition } from '@headlessui/react'
 import { UserCircleIcon, TagIcon } from '@heroicons/react/24/outline'
 import { urlResolver } from '../../js/utils'
@@ -14,7 +15,6 @@ export default function PhotoFooter ({
   hover
 }: PhotoFooterProps): JSX.Element {
   const { username, entityTags } = mediaWithTags
-  const firstTag = entityTags.length > 0 ? entityTags[0] : null
 
   return (
     <Transition
@@ -23,8 +23,8 @@ export default function PhotoFooter ({
       enterFrom='opacity-20'
       enterTo='opacity-100'
     >
-      {firstTag != null && (
-        <DestinationLink {...firstTag} />
+      {entityTags.length > 0 && (
+        <AllTagsLink entityTags={entityTags} />
       )}
       {username != null && <PhotographerLink uid={username} />}
     </Transition>
@@ -40,20 +40,38 @@ const PhotographerLink = ({ uid }: { uid: string }): JSX.Element => (
 )
 
 /**
- * A link to a tag
+ * A component that shows all tags when clicked
  */
-const DestinationLink: React.FC<EntityTag> = ({
-  targetId: id, type, areaName
-}) => {
-  const url = urlResolver(type, id, areaName)
-  if (url == null) return null
+const AllTagsLink: React.FC<{ entityTags: EntityTag[] }> = ({ entityTags }) => {
+  const [showTags, setShowTags] = useState(false)
+
+  const handleClick = (): void => {
+    setShowTags(!showTags)
+  }
+
   return (
-    (
-      <Link href={url}>
-        <span className='absolute bottom-2 left-2 rounded-full bg-gray-100 bg-opacity-70 hover:bg-opacity-100 hover:ring p-2'>
-          <TagIcon className='text-black w-4 h-4' />
-        </span>
-      </Link>
-    )
+    <div className='absolute bottom-2 left-2'>
+      <button
+        onClick={handleClick}
+        className='rounded-full bg-gray-100 bg-opacity-70 hover:bg-opacity-100 hover:ring p-2'
+        aria-label={`Show ${entityTags.length} tags`}
+        title={`Show ${entityTags.length} tags`}
+      >
+        <TagIcon className='text-black w-4 h-4' />
+      </button>
+
+      {showTags && (
+        <div className='absolute bottom-full left-0 mb-2 p-3 bg-white rounded-lg shadow-lg border w-40'>
+          <div className='text-sm font-semibold mb-2'>Tags:</div>
+          <ul className='space-y-1'>
+            {entityTags.map((tag, index) => (
+              <li key={index} className='text-xs'>
+                {tag.climbName ?? tag.areaName ?? 'Untitled'}
+              </li>
+            ))}
+          </ul>
+        </div>
+      )}
+    </div>
   )
 }



Test patch:
diff --git a/src/components/broadcast/__tests__/AppAlert.tsx b/src/components/broadcast/__tests__/AppAlert.tsx
index 6206b97..43807f0 100644
--- a/src/components/broadcast/__tests__/AppAlert.tsx
+++ b/src/components/broadcast/__tests__/AppAlert.tsx
@@ -23,47 +23,52 @@ jest.mock('next-auth/react', () => ({
 
 let AppAlertComponent: React.FC<AppAlertProps>
 
-describe('Banner suppression', () => {
+describe('AppAlert', () => {
   beforeAll(async () => {
     // why async import?  see https://github.com/facebook/jest/issues/10025#issuecomment-716789840
     const module = await import('../AppAlert')
     AppAlertComponent = module.AppAlert
   })
 
-  it('doesn\'t show alert when cookie exists', async () => {
-    // cookie exists
-    cookieGetter.mockReturnValueOnce('foo')
+  beforeEach(() => {
+    cookieGetter.mockClear()
+    cookieSetter.mockClear()
+  })
+
+  it('renders alert with message', () => {
     render(
       <AppAlertComponent
+        cookieStorageKey='test'
         message={
           <div>
             important message
           </div>
-      }
-      />)
+        }
+      />
+    )
 
-    expect(screen.queryAllByRole('button').length).toEqual(0)
-    cookieGetter.mockClear()
+    expect(screen.getByText('important message')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: /Don't show this again/i })).toBeInTheDocument()
   })
 
-  it('shows alert', async () => {
-    // Clear previous cookie setting if any
-    // cookieGetter.mockClear()
-    // cookieGetter.mockRejectedValueOnce(null)
+  it('sets cookie when suppress button is clicked', async () => {
     const user = userEvent.setup({ skipHover: true })
+
     render(
       <AppAlertComponent
+        cookieStorageKey='test'
         message={
           <div>
             important message 2
           </div>
-      }
-      />)
-    screen.debug()
+        }
+      />
+    )
+
     // click the Suppress button
     await user.click(screen.getByRole('button', { name: /Don't show this again/i }))
 
-    // alert dismissed
-    expect(screen.queryAllByRole('button')).toHaveLength(0)
+    // verify cookie was set
+    expect(cookieSetter).toHaveBeenCalledWith('test', '1', { strict: true, expires: 30 })
   })
 })