import React, { useState, useCallback } from 'react' import Modal from '../ui/Modal' import Button from '../ui/Button' const MEDALS = [ { key: 'gold', label: 'Gold', emoji: '🥇', weight: 5 }, { key: 'silver', label: 'Silver', emoji: '🥈', weight: 3 }, { key: 'bronze', label: 'Bronze', emoji: '🥉', weight: 1 }, ] function getMedalMeta(medalKey) { return MEDALS.find((medal) => medal.key === medalKey) ?? null } function getMedalWeight(medalKey) { return getMedalMeta(medalKey)?.weight ?? 0 } function buildConfirmationContent(pendingConfirmation) { if (!pendingConfirmation) { return null } const nextMedal = getMedalMeta(pendingConfirmation.medal) const previousMedal = getMedalMeta(pendingConfirmation.previousMedal) if (pendingConfirmation.action === 'remove') { return { title: `Remove ${nextMedal?.label ?? 'medal'} medal?`, summary: `This will remove your ${nextMedal?.label ?? ''} medal from this artwork.`, details: 'Your contribution to the medal score will be removed immediately after confirmation.', confirmLabel: 'Remove medal', confirmVariant: 'danger', modalVariant: 'danger', } } return { title: `Change medal to ${nextMedal?.label ?? 'selected medal'}?`, summary: `You already awarded ${previousMedal?.label ?? 'a medal'} to this artwork.`, details: `Confirm to switch your medal from ${previousMedal?.label ?? 'the current medal'} to ${nextMedal?.label ?? 'the selected medal'}.`, confirmLabel: `Change to ${nextMedal?.label ?? 'selected medal'}`, confirmVariant: 'accent', modalVariant: 'default', } } function describeMedalError(message) { const normalized = String(message || '').trim() const lower = normalized.toLowerCase() if (lower.includes('verify your email')) { return { title: 'Email verification required', summary: 'Medals are limited to verified accounts to reduce abuse and low-quality vote spam.', details: 'Open your account email, use the verification link, then reload this page and try again.', } } if (lower.includes('at least') && lower.includes('hours old')) { return { title: 'Account is too new', summary: normalized, details: 'This cooldown is there to stop throwaway accounts from mass-awarding artworks immediately after signup.', } } if (lower.includes('your own artwork')) { return { title: 'Own artwork cannot be medaled', summary: 'Creators cannot add medals to their own work.', details: 'Only other community members can award medals so the score stays community-driven.', } } if (lower.includes('not published yet')) { return { title: 'Artwork is not published yet', summary: 'This artwork has not reached a public, medal-eligible state yet.', details: 'Medals are only available after the artwork is published and visible publicly.', } } if (lower.includes('not eligible for medals')) { return { title: 'Artwork is not eligible for medals', summary: 'This artwork is currently blocked from medal voting.', details: 'That usually means it is private, unapproved, or otherwise not available for public medal activity.', } } if (lower.includes('no longer available')) { return { title: 'Artwork is unavailable', summary: 'This artwork can no longer receive medals.', details: 'The artwork may have been removed or is no longer publicly available.', } } if (lower.includes('disabled')) { return { title: 'Medals are temporarily unavailable', summary: normalized, details: 'This is a site-wide setting, not a problem with your account.', } } return { title: 'Unable to add medal', summary: normalized || 'The medal request could not be completed.', details: 'Check that you are signed in with an eligible account and that the artwork is publicly medal-eligible.', } } export default function ArtworkAwards({ artwork, initialAwards = null, isAuthenticated = false }) { const artworkId = artwork?.id const isOwnArtwork = Boolean(artwork?.viewer?.id && artwork?.user?.id && artwork.viewer.id === artwork.user.id) const [awards, setAwards] = useState({ gold: initialAwards?.gold ?? 0, silver: initialAwards?.silver ?? 0, bronze: initialAwards?.bronze ?? 0, score: initialAwards?.score ?? 0, }) const [viewerAward, setViewerAward] = useState(initialAwards?.current_user_medal ?? initialAwards?.viewer_award ?? null) const [loading, setLoading] = useState(null) // which medal is pending const [error, setError] = useState(null) const [pendingConfirmation, setPendingConfirmation] = useState(null) const errorDetails = error ? describeMedalError(error) : null const confirmationContent = buildConfirmationContent(pendingConfirmation) const csrfToken = typeof document !== 'undefined' ? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') : null const apiFetch = useCallback(async (method, body = null) => { const res = await fetch(`/api/artworks/${artworkId}/medal`, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken || '', 'Accept': 'application/json', }, credentials: 'same-origin', body: body ? JSON.stringify(body) : undefined, }) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data?.message || data?.errors?.medal?.[0] || 'Request failed') } return res.json() }, [artworkId, csrfToken]) const applyServerResponse = useCallback((data) => { const payload = data?.medals || data?.awards || null if (payload) { setAwards({ gold: payload.gold ?? 0, silver: payload.silver ?? 0, bronze: payload.bronze ?? 0, score: payload.score ?? 0, }) } setViewerAward(data?.current_user_medal ?? data?.viewer_award ?? null) }, []) const handleMedalAction = useCallback(async ({ action, medal, previousMedal = null }) => { if (!isAuthenticated || isOwnArtwork) return if (loading) return setError(null) // Optimistic update const prevAwards = { ...awards } const prevViewer = viewerAward if (action === 'remove') { // Undo: remove award setAwards(a => ({ ...a, [medal]: Math.max(0, a[medal] - 1), score: Math.max(0, a.score - getMedalWeight(medal)), })) setViewerAward(null) setLoading(medal) try { const data = await apiFetch('DELETE') applyServerResponse(data) } catch (e) { setAwards(prevAwards) setViewerAward(prevViewer) setError(e.message) } finally { setLoading(null) } return } if (action === 'change' && previousMedal) { // Change: swap medals setAwards(a => ({ ...a, [previousMedal]: Math.max(0, a[previousMedal] - 1), [medal]: a[medal] + 1, score: a.score - getMedalWeight(previousMedal) + getMedalWeight(medal), })) setViewerAward(medal) setLoading(medal) try { const data = await apiFetch('POST', { medal_type: medal }) applyServerResponse(data) } catch (e) { setAwards(prevAwards) setViewerAward(prevViewer) setError(e.message) } finally { setLoading(null) } return } // New award setAwards(a => ({ ...a, [medal]: a[medal] + 1, score: a.score + getMedalWeight(medal), })) setViewerAward(medal) setLoading(medal) try { const data = await apiFetch('POST', { medal_type: medal }) applyServerResponse(data) } catch (e) { setAwards(prevAwards) setViewerAward(prevViewer) setError(e.message) } finally { setLoading(null) } }, [isAuthenticated, isOwnArtwork, loading, awards, viewerAward, apiFetch, applyServerResponse]) const handleMedalClick = useCallback((medal) => { if (!isAuthenticated || isOwnArtwork) return if (loading) return setError(null) if (viewerAward === medal) { setPendingConfirmation({ action: 'remove', medal, previousMedal: medal }) return } if (viewerAward) { setPendingConfirmation({ action: 'change', medal, previousMedal: viewerAward }) return } void handleMedalAction({ action: 'add', medal }) }, [isAuthenticated, isOwnArtwork, loading, viewerAward, handleMedalAction]) const closeConfirmation = useCallback(() => { if (loading) return setPendingConfirmation(null) }, [loading]) const confirmPendingAction = useCallback(async () => { if (!pendingConfirmation || loading) return const action = pendingConfirmation setPendingConfirmation(null) await handleMedalAction(action) }, [pendingConfirmation, loading, handleMedalAction]) return ( <>

Medals

{errorDetails && (
{errorDetails.title}

{errorDetails.summary}

{errorDetails.details}

)}
{MEDALS.map(({ key, label, emoji }) => { const isActive = viewerAward === key const isPending = loading === key return ( ) })}
{awards.score > 0 && (

Score: {awards.score}

)} {!isAuthenticated && (

Sign in to medal this artwork

)} {isAuthenticated && isOwnArtwork && (

You cannot medal your own artwork.

)}
) : null} > {confirmationContent ? (

{confirmationContent.summary}

{confirmationContent.details}

) : null}
) }