import type { ServiceAllowance, ServiceIdentityState, ServiceIdentityStatus, ServiceQuotaView } from '@trek/shared'; import { MapPin, RefreshCw, Route, Search, Signal, type LucideIcon } from 'lucide-react'; import { useRef, useState } from 'react'; import { useTranslation } from '../../i18n'; import { useSettingsStore } from '../../store/settingsStore'; import type { TranslationFn } from '../../types'; import { getApiErrorMessage } from '../../utils/apiError'; import { formatClockTime } from '../../utils/formatters'; import { SettingsCard } from '../Settings/settingsKit'; import ConfirmDialog from '../shared/ConfirmDialog'; import { useToast } from '../shared/Toast'; import { FigureBadge } from '../Tours/paddle/FigureBadge'; import { useServiceIdentity } from './useServiceIdentity'; const STATE_DOT: Record = { connected: 'bg-success', registering: 'bg-warning motion-safe:animate-pulse', legacy: 'bg-[var(--text-faint)]', off: 'bg-[var(--text-faint)]', revoked: 'bg-danger', }; const SERVICE_ICON: Record = { places: MapPin, photon: Search, valhalla: Route }; /** Below a tenth of the day left the bar turns amber, at nothing left red. */ function barTone(quota: ServiceQuotaView): string { if (quota.remaining === 0) return 'bg-danger'; return quota.remaining < quota.limit / 10 ? 'bg-warning' : 'bg-accent'; } function figureTone(quota: ServiceQuotaView): string { if (quota.remaining === 0) return 'text-danger'; return quota.remaining < quota.limit / 10 ? 'text-warning' : 'text-content'; } /** The clock time the counts start again, in the user's format; one for all services, which share the day. */ function useResetTime(status: ServiceIdentityStatus | null): string | null { const is12h = useSettingsStore((s) => s.settings.time_format === '12h'); const at = status?.services.find((s) => s.quota)?.quota?.resetAt; if (!at) return null; const reset = new Date(at); return formatClockTime( `${String(reset.getHours()).padStart(2, '0')}:${String(reset.getMinutes()).padStart(2, '0')}`, is12h ); } function tileNote(t: TranslationFn, allowance: ServiceAllowance): string | null { if (!allowance.inUse) return t('admin.trekApi.quota.notInUse'); return allowance.quota ? null : t('admin.trekApi.quota.unavailable'); } /** * One service under the key, as a tile: what is left today as the figure that * matters, the day's use as a bar, used and limit as badges. A service not in * use is dimmed rather than hidden, so the row reads the same on every install. */ function ServiceTile({ allowance }: { allowance: ServiceAllowance }) { const { t, locale } = useTranslation(); const number = new Intl.NumberFormat(locale); const name = t(`admin.trekApi.quota.service.${allowance.service}`); const note = tileNote(t, allowance); const quota = allowance.quota; const Icon = SERVICE_ICON[allowance.service] ?? MapPin; const share = quota && quota.limit > 0 ? Math.min(100, (quota.used / quota.limit) * 100) : 100; return (
  • {name}

    {quota && ( <>

    {number.format(quota.remaining)}

    {t('admin.trekApi.quota.remaining')}

    )} {note &&

    {note}

    }
  • ); } function stateHint(t: TranslationFn, status: ServiceIdentityStatus | null, failed: boolean): string | null { if (!status) return failed ? t('admin.trekApi.quota.unavailable') : null; return status.state === 'connected' ? null : t(`admin.trekApi.quota.hint.${status.state}`); } /** * How this installation stands with the TREK services: whether it has its key, * and for place search, address search and routing today's allowance against * what is used, and when the count starts again. * Read when the card is in view and refreshed every 30 seconds while it stays. * A managed install leaves the identity to its operator, so it offers no new one. */ export default function ServiceIdentityPanel({ managed = false }: { managed?: boolean }) { const { t } = useTranslation(); const toast = useToast(); const panel = useRef(null); const { status, failed, regenerating, regenerate } = useServiceIdentity(panel); const [confirming, setConfirming] = useState(false); const resetAt = useResetTime(status); const confirmRegenerate = async () => { setConfirming(false); try { await regenerate(); toast.success(t('admin.trekApi.quota.regenerated')); } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))); } }; const hint = stateHint(t, status, failed); const canRegenerate = !managed && status !== null && status.keySource !== 'env' && status.state !== 'off'; return (
    } action={ <> {status?.tier && } {status?.keySource === 'env' && ( )} {resetAt && } } > {status && status.services.length > 0 && (
      {status.services.map((allowance) => ( ))}
    )} {hint &&

    {hint}

    } {/* The one action, on a rule of its own below the figures. */} {canRegenerate && (
    )}
    setConfirming(false)} onConfirm={() => void confirmRegenerate()} title={t('admin.trekApi.quota.regenerateTitle')} message={t('admin.trekApi.quota.regenerateBody')} confirmLabel={t('admin.trekApi.quota.regenerate')} danger={false} />
    ); }