fix: harden badge links and polish small flows

This commit is contained in:
SkateSpots 2026-10-31 09:24:32 +07:00
parent 9ce57bedf9
commit 2c8bd594a4
6 changed files with 24 additions and 14 deletions

View file

@ -19,8 +19,7 @@ import { useTranslation } from '@/contexts/language';
const DISCLAIMER_VERSION = 1; const DISCLAIMER_VERSION = 1;
const STORAGE_KEY = `skatespots_disclaimer_accepted_v${DISCLAIMER_VERSION}`; const STORAGE_KEY = `skatespots_disclaimer_accepted_v${DISCLAIMER_VERSION}`;
const REPO_URL = const REPO_URL = 'https://git.skate-spots.org/skatespots/SkateSpots';
'https://github.com/search?q=SkateSpots';
export function BetaDisclaimerDialog() { export function BetaDisclaimerDialog() {
const [accepted, setAccepted] = useLocalStorage<boolean>(STORAGE_KEY, false); const [accepted, setAccepted] = useLocalStorage<boolean>(STORAGE_KEY, false);

View file

@ -11,6 +11,8 @@ import { useTranslation } from '@/contexts/language';
import { LANGUAGE_OPTIONS } from '@/lib/languageOptions'; import { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const VISIBLE_LANGUAGE_OPTIONS = LANGUAGE_OPTIONS.filter((language) => !language.hidden);
interface LanguageToggleProps { interface LanguageToggleProps {
/** Show only the flag icon (compact mode) */ /** Show only the flag icon (compact mode) */
compact?: boolean; compact?: boolean;
@ -65,13 +67,13 @@ export function LanguageToggle({ compact = false, inline = false, className, var
const safeQuery = query.trim().toLowerCase(); const safeQuery = query.trim().toLowerCase();
const filteredLanguages = safeQuery const filteredLanguages = safeQuery
? LANGUAGE_OPTIONS.filter( ? VISIBLE_LANGUAGE_OPTIONS.filter(
(l) => (l) =>
l.name.toLowerCase().includes(safeQuery) || l.name.toLowerCase().includes(safeQuery) ||
l.nativeName.toLowerCase().includes(safeQuery) || l.nativeName.toLowerCase().includes(safeQuery) ||
l.code.toLowerCase().includes(safeQuery) l.code.toLowerCase().includes(safeQuery)
) )
: LANGUAGE_OPTIONS; : VISIBLE_LANGUAGE_OPTIONS;
const currentLanguage = const currentLanguage =
LANGUAGE_OPTIONS.find((l) => l.code === language) || LANGUAGE_OPTIONS[0]; LANGUAGE_OPTIONS.find((l) => l.code === language) || LANGUAGE_OPTIONS[0];
@ -196,7 +198,7 @@ export function LanguageSelector() {
<div className="space-y-3"> <div className="space-y-3">
<label className="text-sm font-medium">{t('settings.selectLanguage')}</label> <label className="text-sm font-medium">{t('settings.selectLanguage')}</label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{LANGUAGE_OPTIONS.map((lang) => ( {VISIBLE_LANGUAGE_OPTIONS.map((lang) => (
<button <button
key={lang.code} key={lang.code}
onClick={() => setLanguage(lang.code)} onClick={() => setLanguage(lang.code)}

View file

@ -21,7 +21,10 @@ export function PaymentReadinessNote() {
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
const lud16 = metadata?.lud16; const lud16 = metadata?.lud16;
const hasLightning = isValidLightningAddress(lud16) || !!metadata?.lud06; // PayWithSats falls back to the profile's lud16. A lud06/LNURL alone is
// not enough to settle a marketplace offer, so do not advertise readiness
// for a payment path checkout cannot actually use.
const hasLightning = isValidLightningAddress(lud16);
// Silent payment lives in a custom kind-0 field the app reads elsewhere. // Silent payment lives in a custom kind-0 field the app reads elsewhere.
const rawSp = (metadata as Record<string, unknown> | undefined)?.bitcoin_silent_payment; const rawSp = (metadata as Record<string, unknown> | undefined)?.bitcoin_silent_payment;
const hasSilentPayment = typeof rawSp === 'string' && isValidSilentPayment(rawSp); const hasSilentPayment = typeof rawSp === 'string' && isValidSilentPayment(rawSp);

View file

@ -1,6 +1,7 @@
import { BadgeCheck, ExternalLink } from 'lucide-react'; import { BadgeCheck, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SKATESPOTS_SITE, type ProfileBadge } from '@/lib/badges'; import { SKATESPOTS_SITE, type ProfileBadge } from '@/lib/badges';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
/** The app logo is already served with the site. */ /** The app logo is already served with the site. */
const SKATESPOTS_LOGO = '/images/logo-icon.png'; const SKATESPOTS_LOGO = '/images/logo-icon.png';
@ -26,6 +27,7 @@ export function ProfileBadges({
<div className={cn('flex flex-wrap items-center gap-2', className)}> <div className={cn('flex flex-wrap items-center gap-2', className)}>
{badges.map((b, i) => { {badges.map((b, i) => {
const app = isApp(b); const app = isApp(b);
const safeUrl = b.url ? sanitizeUrl(b.url) : null;
const cls = cn( const cls = cn(
'inline-flex items-center gap-1.5 rounded-full border font-bold transition-colors', 'inline-flex items-center gap-1.5 rounded-full border font-bold transition-colors',
size === 'sm' ? 'px-2 py-0.5 text-[11px]' : 'px-3 py-1.5 text-sm', size === 'sm' ? 'px-2 py-0.5 text-[11px]' : 'px-3 py-1.5 text-sm',
@ -41,17 +43,17 @@ export function ProfileBadges({
<BadgeCheck className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'} /> <BadgeCheck className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
)} )}
<span className="max-w-[12rem] truncate">{b.label}</span> <span className="max-w-[12rem] truncate">{b.label}</span>
{b.url && !app && <ExternalLink className="h-3 w-3 opacity-60" />} {safeUrl && !app && <ExternalLink className="h-3 w-3 opacity-60" />}
</> </>
); );
return b.url ? ( return safeUrl ? (
<a <a
key={`${b.label}-${i}`} key={`${b.label}-${i}`}
href={b.url} href={safeUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title={b.url} title={safeUrl}
className={cls} className={cls}
> >
{inner} {inner}

View file

@ -5,6 +5,8 @@ export interface LanguageOption {
name: string; name: string;
nativeName: string; nativeName: string;
flag: string; flag: string;
/** Kept for stored preferences, but omitted from the visible picker. */
hidden?: boolean;
} }
/** /**
@ -26,7 +28,9 @@ export const LANGUAGE_OPTIONS: LanguageOption[] = [
{ code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱' }, { code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱' },
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺' }, { code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺' },
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷' }, { code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷' },
{ code: 'ko-KP', name: 'Korean (North)', nativeName: '조선어', flag: '🇰🇵' }, // North Korean reuses the Korean dictionary; hide the duplicate from the
// picker while keeping the code valid for existing stored preferences.
{ code: 'ko-KP', name: 'Korean (North)', nativeName: '조선어', flag: '🇰🇵', hidden: true },
{ code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' }, { code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' }, { code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' },
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文', flag: '🇨🇳' }, { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文', flag: '🇨🇳' },

View file

@ -1,5 +1,5 @@
import { useSeoMeta } from "@unhead/react"; import { useSeoMeta } from "@unhead/react";
import { useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -24,9 +24,9 @@ const NotFound = () => {
<div className="text-center"> <div className="text-center">
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-gray-100">404</h1> <h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-gray-100">404</h1>
<p className="text-xl text-gray-600 dark:text-gray-400 mb-4">{t('settingsUi.notFound.message')}</p> <p className="text-xl text-gray-600 dark:text-gray-400 mb-4">{t('settingsUi.notFound.message')}</p>
<a href="/" className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"> <Link to="/" className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline">
{t('settingsUi.notFound.returnHome')} {t('settingsUi.notFound.returnHome')}
</a> </Link>
</div> </div>
</div> </div>
); );