fix: tier 3 — medium bugs & hardening
- Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS guard EventSponsors already uses. - Official SkateSpots badge only shows when the NIP-05 actually verifies (NIP19Page), not on a self-asserted handle. - Community stats reads are null-guarded on persisted-cache fields (monthly/cumulativeSpots/topMappers) — no more crash on an old cache. - Overpass response no longer assumes .elements (food layer). - user-listings query key is memoized (was Date.now() → refetch storm). - Pro grant requires the l=pro label (was accepted with none). - Verify card RSVP maps accepted/tentative (was going/interested → showed 'No RSVP' on a valid ticket). - Expired listings can't be bought via a stale/shared URL. - Batch spot photo/status hooks include the relay suffix and use querySkateContent (relay-policy parity with useSpotReports). - Offline queue requeues rows stuck in 'publishing' after a crash. - querySkateContent/queryCanonical dedupe replaceable events by author+kind (not id) so a stale profile/relay list can't win. - Route offline cache key includes the origin (was destination+mode only). - Internal links use <Link> (MarketplacePage); EventCountdown reacts to language changes.
This commit is contained in:
parent
582089c1e3
commit
e016ee20bd
17 changed files with 101 additions and 68 deletions
|
|
@ -61,9 +61,9 @@ export function EventCountdown({
|
|||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// `tick` is a dependency so the label actually recomputes on each interval.
|
||||
// `tick` forces a periodic recompute; `t` so a language switch updates it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const info = useMemo(() => computeStatus(start, end, t), [start, end, tick]);
|
||||
const info = useMemo(() => computeStatus(start, end, t), [start, end, tick, t]);
|
||||
|
||||
const styles: Record<EventStatus, string> = {
|
||||
live: 'bg-[#ff0040] text-white border-[#ff0040] animate-pulse',
|
||||
|
|
|
|||
|
|
@ -86,11 +86,13 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
const iConfirmedTrade = !!trade?.iConfirmed;
|
||||
const iRequestedConfirmation = !!requestState?.iRequested;
|
||||
|
||||
const isExpired = !!item.expiration && item.expiration < Math.floor(Date.now() / 1000);
|
||||
const canBuy =
|
||||
!!user &&
|
||||
!isOwnListing &&
|
||||
!isWTB &&
|
||||
!isSold &&
|
||||
!isExpired &&
|
||||
!iConfirmedTrade &&
|
||||
item.priceType === 'fixed' &&
|
||||
typeof buyAmountSats === 'number' &&
|
||||
|
|
|
|||
|
|
@ -706,7 +706,7 @@ export function RouteDialog({
|
|||
setCachedAge(null);
|
||||
setRoute(result);
|
||||
setTransitIndex(0);
|
||||
if (mode !== 'transit') saveRoute(dest, mode, result);
|
||||
if (mode !== 'transit') saveRoute(effectiveOrigin, dest, mode, result);
|
||||
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
|
|
@ -754,7 +754,7 @@ export function RouteDialog({
|
|||
// Offline / server down: fall back to the last route we saved for this
|
||||
// destination + mode, clearly labelled, instead of a dead dialog.
|
||||
if (mode !== 'transit') {
|
||||
const hit = loadRoute(dest, mode);
|
||||
const hit = loadRoute(effectiveOrigin, dest, mode);
|
||||
if (hit) {
|
||||
setRoute(hit.route);
|
||||
setCachedAge(hit.ageMs);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
export interface Sponsor {
|
||||
name: string;
|
||||
|
|
@ -106,6 +107,10 @@ export function SponsorSlot({
|
|||
|
||||
// Individual sponsor display
|
||||
function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'card' | 'inline' }) {
|
||||
// SECURITY: sponsor data comes from event tags (a tour author controls it),
|
||||
// so sanitize before use as an href/src — same as EventSponsors.
|
||||
const safeUrl = sponsor.url ? sanitizeUrl(sponsor.url) : null;
|
||||
const safeLogo = sponsor.logo ? sanitizeUrl(sponsor.logo) : null;
|
||||
const content = (
|
||||
<div className={cn(
|
||||
'flex items-center gap-2 transition-opacity hover:opacity-80',
|
||||
|
|
@ -113,9 +118,9 @@ function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'ca
|
|||
size === 'card' && 'flex-row',
|
||||
size === 'inline' && 'inline-flex',
|
||||
)}>
|
||||
{sponsor.logo && (
|
||||
{safeLogo && (
|
||||
<img
|
||||
src={sponsor.logo}
|
||||
src={safeLogo}
|
||||
alt={sponsor.name}
|
||||
className={cn(
|
||||
'object-contain',
|
||||
|
|
@ -130,7 +135,7 @@ function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'ca
|
|||
size === 'banner' && 'text-sm text-gray-400',
|
||||
size === 'card' && 'text-sm text-gray-300',
|
||||
size === 'inline' && 'text-xs text-gray-400',
|
||||
!sponsor.logo && 'text-gray-300', // More visible if no logo
|
||||
!safeLogo && 'text-gray-300', // More visible if no logo
|
||||
)}>
|
||||
{sponsor.name}
|
||||
</span>
|
||||
|
|
@ -140,10 +145,10 @@ function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'ca
|
|||
</div>
|
||||
);
|
||||
|
||||
if (sponsor.url) {
|
||||
if (safeUrl) {
|
||||
return (
|
||||
<a
|
||||
href={sponsor.url}
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
|
|
|
|||
|
|
@ -70,8 +70,12 @@ export function useFoodSpots(geohash: string | undefined, radiusKm = 1) {
|
|||
|
||||
const data = await response.json();
|
||||
|
||||
const foodSpots: FoodSpot[] = data.elements
|
||||
.map((element: OverpassElement) => {
|
||||
// Overpass returns an error/status payload without `elements` on failure;
|
||||
// never assume it exists (a bare `.map` crashed the food layer).
|
||||
const elements: OverpassElement[] = Array.isArray(data?.elements) ? data.elements : [];
|
||||
|
||||
const foodSpots: FoodSpot[] = elements
|
||||
.map((element: OverpassElement): FoodSpot | null => {
|
||||
const lat = element.lat ?? element.center?.lat;
|
||||
const lon = element.lon ?? element.center?.lon;
|
||||
const tags = element.tags ?? {};
|
||||
|
|
@ -96,7 +100,7 @@ export function useFoodSpots(geohash: string | undefined, radiusKm = 1) {
|
|||
distance,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((s): s is FoodSpot => s !== null)
|
||||
.sort((a: FoodSpot, b: FoodSpot) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
|
||||
|
||||
return foodSpots as FoodSpot[];
|
||||
|
|
|
|||
|
|
@ -108,7 +108,12 @@ export function useListingRestrictions(linkedSpotPubkey?: string): ListingRestri
|
|||
});
|
||||
|
||||
// Query user's listings (last 30 days)
|
||||
const thirtyDaysAgo = Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000);
|
||||
// Day-granular + memoized: computing this from Date.now() each render changed
|
||||
// the query key continuously and caused a refetch storm.
|
||||
const thirtyDaysAgo = useMemo(
|
||||
() => Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000),
|
||||
[],
|
||||
);
|
||||
const { data: userListings, isLoading: listingsLoading } = useQuery({
|
||||
queryKey: ['user-listings', user?.pubkey, thirtyDaysAgo],
|
||||
queryFn: async (c) => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
getPendingPosts,
|
||||
getPendingPostsCount,
|
||||
prunePendingPosts,
|
||||
reconcileStalePendingPosts,
|
||||
removePendingPost,
|
||||
updatePendingPostStatus,
|
||||
} from '@/lib/offlineCache';
|
||||
|
|
@ -201,6 +202,7 @@ export function useSyncPendingPosts() {
|
|||
if (cancelled || syncingRef.current || globalSyncing) return;
|
||||
|
||||
await prunePendingPosts().catch(() => {});
|
||||
await reconcileStalePendingPosts().catch(() => {});
|
||||
const pending = await getPendingPosts();
|
||||
if (pending.length === 0) return;
|
||||
if (cancelled) return;
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ function grantExpiry(grant: NostrEvent, userPubkey: string): number | null {
|
|||
const namespace = grant.tags.filter(([n]) => n === 'L').map(([, v]) => v);
|
||||
if (!namespace.includes(PRO_LABEL_NAMESPACE)) return null;
|
||||
const value = grant.tags.filter(([n]) => n === 'l').map(([, v]) => v);
|
||||
if (value.length && !value.includes(PRO_LABEL_VALUE)) return null;
|
||||
if (!value.includes(PRO_LABEL_VALUE)) return null;
|
||||
const taggedUser = grant.tags.some(([n, v]) => n === 'p' && v === userPubkey);
|
||||
if (!taggedUser) return null;
|
||||
const expiry = Number(tagVal(grant.tags, 'expiry'));
|
||||
|
|
|
|||
|
|
@ -6,6 +6,30 @@ import { useCitrine } from '@/hooks/useCitrine';
|
|||
import { SKATE_RELAY } from '@/lib/types';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Deduplicate a batch of events from several relays, keeping the newest per
|
||||
* identity:
|
||||
* - addressable (has `d`): `pubkey:kind:d-tag`;
|
||||
* - replaceable (0, 3, 10000–19999): `pubkey:kind` (NIP-01 keeps only the
|
||||
* latest per author+kind — keying by id would surface stale profiles/relay
|
||||
* lists when relays return different versions);
|
||||
* - otherwise: the event id.
|
||||
*/
|
||||
function dedupeEvents(events: NostrEvent[]): NostrEvent[] {
|
||||
const map = new Map<string, NostrEvent>();
|
||||
for (const event of events) {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
let key: string;
|
||||
if (dTag) key = `${event.pubkey}:${event.kind}:${dTag}`;
|
||||
else if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
|
||||
key = `${event.pubkey}:${event.kind}`;
|
||||
} else key = event.id;
|
||||
const existing = map.get(key);
|
||||
if (!existing || event.created_at > existing.created_at) map.set(key, event);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides relay-aware query functions for skate-specific content.
|
||||
*
|
||||
|
|
@ -120,26 +144,7 @@ export function useSkateRelays() {
|
|||
|
||||
const allEvents = results.flat();
|
||||
|
||||
// Deduplicate addressable events by pubkey+kind+d-tag
|
||||
const eventMap = new Map<string, NostrEvent>();
|
||||
for (const event of allEvents) {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
if (dTag) {
|
||||
// Addressable event - dedupe by coordinate
|
||||
const key = `${event.pubkey}:${event.kind}:${dTag}`;
|
||||
const existing = eventMap.get(key);
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
eventMap.set(key, event);
|
||||
}
|
||||
} else {
|
||||
// Regular event - dedupe by id
|
||||
eventMap.set(event.id, event);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueEvents = Array.from(eventMap.values());
|
||||
|
||||
return uniqueEvents;
|
||||
return dedupeEvents(allEvents);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -159,19 +164,7 @@ export function useSkateRelays() {
|
|||
const relay = nostr.relay(SKATE_RELAY);
|
||||
const events = await relay.query(filters, { signal: options.signal });
|
||||
|
||||
// Dedupe addressable events by pubkey+kind+d-tag (keep newest).
|
||||
const eventMap = new Map<string, NostrEvent>();
|
||||
for (const event of events) {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
if (dTag) {
|
||||
const key = `${event.pubkey}:${event.kind}:${dTag}`;
|
||||
const existing = eventMap.get(key);
|
||||
if (!existing || event.created_at > existing.created_at) eventMap.set(key, event);
|
||||
} else {
|
||||
eventMap.set(event.id, event);
|
||||
}
|
||||
}
|
||||
return Array.from(eventMap.values());
|
||||
return dedupeEvents(events);
|
||||
} catch (err) {
|
||||
// See querySkateContent: an outage must not look like "empty".
|
||||
options.onError?.();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react';
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
|
||||
// Kind 1063 = File Metadata (NIP-94) for community photos
|
||||
export const PHOTO_CONTRIBUTION_KIND = 1063;
|
||||
|
|
@ -197,7 +198,7 @@ export function useAggregatedSpotTags(spotPubkey: string, spotIdentifier: string
|
|||
* own picture is expected, not a community signal.
|
||||
*/
|
||||
export function useBatchSpotPhotos(spots: Array<{ pubkey: string; identifier: string }>) {
|
||||
const { nostr } = useNostr();
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
const refs = useMemo(
|
||||
() => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`),
|
||||
|
|
@ -205,12 +206,12 @@ export function useBatchSpotPhotos(spots: Array<{ pubkey: string; identifier: st
|
|||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['batch-spot-photos', refs.slice().sort().join(',')],
|
||||
queryKey: ['batch-spot-photos', getQueryKeySuffix(), refs.slice().sort().join(',')],
|
||||
enabled: refs.length > 0,
|
||||
staleTime: 60_000,
|
||||
queryFn: async (c) => {
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
||||
const events = await nostr.query(
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [PHOTO_CONTRIBUTION_KIND], '#a': refs, limit: 1000 }],
|
||||
{ signal },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
|
||||
/** Reports older than this are ignored entirely (spots do change back). */
|
||||
const REPORT_MAX_AGE_SECONDS = 180 * 24 * 60 * 60; // 180 days
|
||||
|
|
@ -173,7 +174,7 @@ export function useSpotStatus(spotPubkey: string, spotIdentifier: string) {
|
|||
* Returns a Map keyed by spot coordinate (`30818:pubkey:dtag`).
|
||||
*/
|
||||
export function useBatchSpotStatus(spots: Array<{ pubkey: string; identifier: string }>) {
|
||||
const { nostr } = useNostr();
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
const spotRefs = useMemo(
|
||||
() => spots.map((s) => spotRefFor(s.pubkey, s.identifier)),
|
||||
|
|
@ -181,12 +182,12 @@ export function useBatchSpotStatus(spots: Array<{ pubkey: string; identifier: st
|
|||
);
|
||||
|
||||
const reportsQuery = useQuery({
|
||||
queryKey: ['batch-spot-status', spotRefs.slice().sort().join(',')],
|
||||
queryKey: ['batch-spot-status', getQueryKeySuffix(), spotRefs.slice().sort().join(',')],
|
||||
queryFn: async (c) => {
|
||||
if (spotRefs.length === 0) return [] as NostrEvent[];
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
||||
// Relays index single-letter tags, so '#a' with many refs is one query.
|
||||
const events = await nostr.query(
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [SPOT_STATUS_KIND], '#a': spotRefs, limit: 1000 }],
|
||||
{ signal },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -350,6 +350,18 @@ export async function getPendingPostsCount(): Promise<number> {
|
|||
return db.pendingPosts.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Requeue posts left in `publishing` by a crash/reload. Without this they are
|
||||
* never retried (`getPendingPosts` skips `publishing`) yet still counted, so the
|
||||
* action silently disappears while the badge claims it is still pending.
|
||||
*/
|
||||
export async function reconcileStalePendingPosts(): Promise<void> {
|
||||
const db = getDB();
|
||||
const all = await db.pendingPosts.toArray();
|
||||
const stuck = all.filter((p) => p.status === 'publishing');
|
||||
await Promise.all(stuck.map((p) => db.pendingPosts.update(p.id, { status: 'pending' })));
|
||||
}
|
||||
|
||||
// ============ Encrypted Stash Cache (offline readability) ============
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -26,8 +26,10 @@ interface StoredRoute {
|
|||
|
||||
type Store = Record<string, StoredRoute>;
|
||||
|
||||
function keyFor(to: { lat: number; lng: number }, mode: string): string {
|
||||
return `${to.lat.toFixed(4)},${to.lng.toFixed(4)}:${mode}`;
|
||||
function keyFor(from: { lat: number; lng: number }, to: { lat: number; lng: number }, mode: string): string {
|
||||
// Origin is part of the key: a route saved from a different start point is not
|
||||
// a valid fallback for the one the user chose (the "Saved route" label would lie).
|
||||
return `${from.lat.toFixed(4)},${from.lng.toFixed(4)}>${to.lat.toFixed(4)},${to.lng.toFixed(4)}:${mode}`;
|
||||
}
|
||||
|
||||
function read(): Store {
|
||||
|
|
@ -60,13 +62,14 @@ function downsample(coords: Array<[number, number]>, max = MAX_POINTS): Array<[n
|
|||
|
||||
/** Remember a freshly-fetched route (best effort). */
|
||||
export function saveRoute(
|
||||
from: { lat: number; lng: number },
|
||||
to: { lat: number; lng: number },
|
||||
mode: string,
|
||||
route: RouteResult,
|
||||
): void {
|
||||
if (!route.coordinates?.length) return;
|
||||
const store = read();
|
||||
store[keyFor(to, mode)] = {
|
||||
store[keyFor(from, to, mode)] = {
|
||||
at: Date.now(),
|
||||
distanceM: route.distanceM,
|
||||
durationS: route.durationS,
|
||||
|
|
@ -82,10 +85,11 @@ export function saveRoute(
|
|||
|
||||
/** Read a cached route for this destination+mode, with how old it is. */
|
||||
export function loadRoute(
|
||||
from: { lat: number; lng: number },
|
||||
to: { lat: number; lng: number },
|
||||
mode: string,
|
||||
): { route: RouteResult; ageMs: number } | null {
|
||||
const stored = read()[keyFor(to, mode)];
|
||||
const stored = read()[keyFor(from, to, mode)];
|
||||
if (!stored || !Array.isArray(stored.coordinates) || stored.coordinates.length < 2) return null;
|
||||
return {
|
||||
route: {
|
||||
|
|
|
|||
|
|
@ -205,11 +205,14 @@ export default function CommunityStatsPage() {
|
|||
}, [stats, t]);
|
||||
|
||||
// Only show last 12 months on charts to keep them readable
|
||||
const recentMonthly = useMemo(() => stats?.monthly.slice(-12) ?? [], [stats]);
|
||||
const recentCumulative = useMemo(() => stats?.cumulativeSpots.slice(-12) ?? [], [stats]);
|
||||
const recentMonthly = useMemo(() => stats?.monthly?.slice(-12) ?? [], [stats]);
|
||||
const recentCumulative = useMemo(() => stats?.cumulativeSpots?.slice(-12) ?? [], [stats]);
|
||||
|
||||
// Current month delta — how many items were added this month
|
||||
const thisMonth = useMemo(() => stats?.monthly[stats.monthly.length - 1], [stats]);
|
||||
const thisMonth = useMemo(
|
||||
() => (stats?.monthly?.length ? stats.monthly[stats.monthly.length - 1] : undefined),
|
||||
[stats],
|
||||
);
|
||||
|
||||
// Total across ALL Lab creator/service crafts (matches what /lab lists)
|
||||
const labTotal = useMemo(() => (stats ? labCraftTotal(stats) : 0), [stats]);
|
||||
|
|
@ -630,11 +633,11 @@ export default function CommunityStatsPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : stats && stats.topMappers.length > 0 ? (
|
||||
) : stats && (stats.topMappers ?? []).length > 0 ? (
|
||||
<>
|
||||
<Card className="bg-black/80 border border-white/8">
|
||||
<CardContent className="pt-5">
|
||||
{stats.topMappers.slice(0, 5).map((m, i) => (
|
||||
{(stats.topMappers ?? []).slice(0, 5).map((m, i) => (
|
||||
<MapperRow key={m.pubkey} pubkey={m.pubkey} rank={i + 1} spotCount={m.spotCount} />
|
||||
))}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
|
|
@ -192,7 +193,7 @@ export default function MarketplacePage() {
|
|||
<div>
|
||||
<h4 className="font-bold text-[#ff0040] mb-1">{t('marketUi.marketplace.needBitcoinTitle')}</h4>
|
||||
<p className="text-sm text-gray-300">
|
||||
{t('marketUi.marketplace.needBitcoinBefore')} <a href="/onboarding" className="text-[#ccff00] underline font-bold">{t('marketUi.marketplace.onboardingGuide')}</a> {t('marketUi.marketplace.needBitcoinAfter')}
|
||||
{t('marketUi.marketplace.needBitcoinBefore')} <Link to="/onboarding" className="text-[#ccff00] underline font-bold">{t('marketUi.marketplace.onboardingGuide')}</Link> {t('marketUi.marketplace.needBitcoinAfter')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -209,12 +210,12 @@ export default function MarketplacePage() {
|
|||
{t('marketUi.marketplace.ownShopBody')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="/create-shop">
|
||||
<Link to="/create-shop">
|
||||
<Button className="gap-2 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white border-2 border-[#ff0040] shadow-[0_0_20px_rgba(255,0,64,0.5)] font-bold uppercase px-8">
|
||||
<Store className="h-5 w-5" />
|
||||
{t('marketUi.marketplace.addShop')}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<a href="https://plebeian.market/sell" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" className="gap-2 border-2 border-[#ccff00]/50 hover:bg-[#ccff00]/10 text-[#ccff00] font-bold uppercase px-8">
|
||||
<Package className="h-5 w-5" />
|
||||
|
|
|
|||
|
|
@ -290,7 +290,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
)}
|
||||
{/* SkateSpots member badge — the app logo, linking to the site.
|
||||
Shown for any handle issued by our own domain (name@skate-spots.org). */}
|
||||
{isSkateSpotsNip05(nip05) && (
|
||||
{nip05Verified && isSkateSpotsNip05(nip05) && (
|
||||
<ProfileBadges
|
||||
badges={[SKATESPOTS_BADGE]}
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export default function VerifyCardPage() {
|
|||
);
|
||||
const ev = [...events].sort((a, b) => b.created_at - a.created_at)[0];
|
||||
if (!ev) return { status: 'none' };
|
||||
return { status: ev.tags.find(([n]) => n === 'status')?.[1] ?? 'going' };
|
||||
return { status: ev.tags.find(([n]) => n === 'status')?.[1] ?? 'accepted' };
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -240,7 +240,7 @@ export default function VerifyCardPage() {
|
|||
{isTicket && ticket.data && (
|
||||
<Row
|
||||
label={t('cardUi.verify.rsvp')}
|
||||
value={ticket.data.status === 'going' ? t('cardUi.verify.rsvpGoing') : ticket.data.status === 'interested' ? t('cardUi.verify.rsvpInterested') : t('cardUi.verify.rsvpNone')}
|
||||
value={(ticket.data.status === 'accepted' || ticket.data.status === 'going') ? t('cardUi.verify.rsvpGoing') : (ticket.data.status === 'tentative' || ticket.data.status === 'interested') ? t('cardUi.verify.rsvpInterested') : t('cardUi.verify.rsvpNone')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue