Files
TREK/client/src/components/shared/StarRating.tsx
T
Maurice 18a16d2aa2 fix(mobile): forms on the shared form sheet, and the details the review found
Expense, payment, booking, transport, day note, place edit, new trip,
journey entry, collab note, poll, task, packing item and packing import
use the form sheet: one header with its icon tile, a body that scrolls
with a fade, the footer, and full height with the actions beside the
title on a short window. Create New Trip loses its nested section cards.

Details:
- Add payment gets its icon tile and the currency inside the amount field.
- The expense's category field has the same fill as every other field.
- Day and date fields keep their year at phone width; From and To stack
  in a narrow form.
- The place sheet's category chip sits under the name, uncut, the photo
  on the cards' leading edge, labels in the sheets' small capitals.
- Stars no longer keep a hover preview after a tap.
- A hotel booking shows its stay's check-in and check-out when it has
  none of its own, instead of the grey hint reading as a saved time.
- The collab note's title field is labelled Title (common.title).
- Shorter airport hint in German and Russian; long hints end in an ellipsis.
- The journey story grows with its lines (useAutoGrow).
- Member avatars show the member's initial, yours too, and turn over on
  a selected chip.
- The trip's More sheet scrolls, dims a short window and is marked in
  the dock while open; long titles wrap to two lines.
2026-11-07 23:25:14 +01:00

157 lines
6.7 KiB
TypeScript

import React, { useState } from 'react'
import { createPortal } from 'react-dom'
import { Star } from 'lucide-react'
import { avatarSrc } from '../../utils/avatarSrc'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import type { PlaceRatingVote } from '@trek/shared'
const STAR_FILL = '#facc15'
interface StarsProps {
/** Value the stars visualize (average for display, own vote while hovering). */
value: number
size?: number
/** Interactive mode: hover previews, click casts/clears the own vote. */
onRate?: (rating: number | null) => void
myRating?: number | null
ariaLabel?: string
}
/** Five stars with fractional fill; interactive when onRate is set (#1435).
* Clicking the star matching the own vote clears it. */
export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }: StarsProps) {
const [hover, setHover] = useState<number | null>(null)
const shown = hover ?? value
return (
<div
role={onRate ? 'radiogroup' : undefined}
aria-label={ariaLabel}
style={{ display: 'inline-flex', gap: 2, cursor: onRate ? 'pointer' : 'default' }}
onPointerLeave={() => setHover(null)}
>
{[1, 2, 3, 4, 5].map(i => {
// Fractional fill for averages: full below, empty above, clipped between.
const fill = Math.max(0, Math.min(1, shown - (i - 1)))
const star = (
<span key={i} style={{ position: 'relative', display: 'inline-flex', width: size, height: size }}>
<Star size={size} color={STAR_FILL} fill="none" style={{ position: 'absolute', inset: 0 }} />
{fill > 0 && (
<span style={{ position: 'absolute', inset: 0, width: `${fill * 100}%`, overflow: 'hidden' }}>
<Star size={size} color={STAR_FILL} fill={STAR_FILL} />
</span>
)}
</span>
)
if (!onRate) return star
return (
<button
key={i}
type="button"
role="radio"
aria-checked={myRating === i}
aria-label={`${i}`}
// A preview for a pointer that hovers (a mouse, a trackpad, a pen held
// above the screen). A finger has no hover: the enter it fires on a
// tap has no leave after it, so the preview stuck and an unrated
// place showed five full stars beside "Not rated yet".
onPointerEnter={e => { if (e.pointerType !== 'touch') setHover(i) }}
onFocus={() => setHover(i)}
onBlur={() => setHover(null)}
onClick={() => onRate(myRating === i ? null : i)}
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex' }}
>
{star}
</button>
)
})}
</div>
)
}
interface PlaceRatingProps {
ratings: PlaceRatingVote[]
ratingAvg: number | null | undefined
onRate?: (rating: number | null) => void
size?: number
/** Hide the voter avatar strip (compact mobile rows). */
compact?: boolean
}
/**
* The collaborative rating row (#1435): interactive stars (hover previews the
* own vote, the average fill shows otherwise), the numeric average, and the
* voter avatars. Hovering reveals a custom tooltip listing every voter with
* their stars.
*/
export default function PlaceRating({ ratings, ratingAvg, onRate, size = 16, compact = false }: PlaceRatingProps) {
const { t } = useTranslation()
const currentUserId = useAuthStore(s => s.user?.id)
const [tip, setTip] = useState<{ top: number; left: number } | null>(null)
const myRating = ratings.find(r => r.user_id === currentUserId)?.rating ?? null
const avg = ratingAvg ?? null
const showTip = (e: React.MouseEvent) => {
if (ratings.length === 0) return
const r = e.currentTarget.getBoundingClientRect()
setTip({ top: r.top, left: r.left + r.width / 2 })
}
return (
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, minHeight: size + 4 }}
onMouseEnter={showTip}
onMouseLeave={() => setTip(null)}
>
<Stars value={avg ?? 0} size={size} onRate={onRate} myRating={myRating} ariaLabel={t('places.yourRating')} />
{avg !== null ? (
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', fontVariantNumeric: 'tabular-nums' }}>
{(Math.round(avg * 10) / 10).toLocaleString()} <span style={{ fontWeight: 400, color: 'var(--text-faint)' }}>({ratings.length})</span>
</span>
) : (
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('places.notRated')}</span>
)}
{/* Who voted — avatar strip. */}
{!compact && ratings.length > 0 && (
<span style={{ display: 'inline-flex', marginInlineStart: 'auto' }}>
{ratings.slice(0, 6).map((r, i) => (
<span key={r.user_id} className="bg-surface-tertiary text-content-muted" style={{
width: 18, height: 18, borderRadius: '50%', overflow: 'hidden', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 700,
marginInlineStart: i ? -5 : 0, border: '1.5px solid var(--bg-elevated, #fff)',
}}>
{r.avatar ? <img src={avatarSrc(r.avatar)!} alt={r.username || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : r.username?.[0]?.toUpperCase()}
</span>
))}
</span>
)}
{/* Custom voter tooltip — fixed at the root so nothing clips it. */}
{tip && ratings.length > 0 && createPortal(
<div
role="tooltip"
className="bg-surface-card text-content border border-edge-faint"
style={{
position: 'fixed', top: tip.top - 8, left: tip.left, transform: 'translate(-50%, -100%)',
zIndex: 100000, pointerEvents: 'none', borderRadius: 10, padding: '8px 11px', minWidth: 140,
boxShadow: '0 4px 16px rgba(0,0,0,0.15)', fontFamily: 'var(--font-system)',
}}
>
{ratings.map(r => (
<div key={r.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '2px 0' }}>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.username}{r.user_id === currentUserId ? ` (${t('vacay.you')})` : ''}
</span>
<Stars value={r.rating} size={11} />
</div>
))}
</div>,
document.body,
)}
</div>
)
}