diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 24fa98b..df3d4ea 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -79,7 +79,6 @@ const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage")); const PublicCardPage = lazyWithRetry(() => import("./pages/PublicCardPage")); const VerifyCardPage = lazyWithRetry(() => import("./pages/VerifyCardPage")); const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage")); -const MarketplacePage = lazyWithRetry(() => import("./pages/MarketplacePage")); const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage")); const GlossaryPage = lazyWithRetry(() => import("./pages/GlossaryPage")); const NotFound = lazyWithRetry(() => import("./pages/NotFound")); @@ -200,7 +199,7 @@ export function AppRouter() { } /> } /> {/* Marketplace */} - } /> + } /> {/* Newsletter */} } /> {/* Trick Glossary */} diff --git a/src/components/GearSwapItemCard.tsx b/src/components/GearSwapItemCard.tsx index 08e3157..eb83c0b 100644 --- a/src/components/GearSwapItemCard.tsx +++ b/src/components/GearSwapItemCard.tsx @@ -1,10 +1,11 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { ImageWithFallback } from '@/components/ImageWithFallback'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -52,6 +53,13 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC const [showMarkSold, setShowMarkSold] = useState(false); const npub = nip19.npubEncode(item.pubkey); + const listingPath = `/listing/${item.pubkey}/${item.identifier}`; + // Keep shared links canonical in the native WebView too (capacitor://localhost + // is not a shareable origin). + const listingUrl = `https://skate-spots.org${listingPath}`; + const contactSeller = () => navigate( + `/messages/${npub}?message=${encodeURIComponent(t('marketUi.card.contactDm', { title: item.title, url: listingUrl }))}`, + ); const isOwnListingCheck = user?.pubkey === item.pubkey; const isAdmin = isAdminPubkey(user?.pubkey); @@ -127,21 +135,28 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC return ( navigate(`/listing/${item.pubkey}/${item.identifier}`)} > - + {/* Stretched link: real link semantics, context menu, middle-click and + keyboard activation. Interactive controls above re-enable pointer events. */} + + {/* Image */}
{primaryImage ? ( - {item.title}{isWTB ? '🔍' : '🛹'}} /> ) : (
+
+ {showDraftNotice && ( + + +
+

{t('marketUi.create.draftRestored')}

+

{t('marketUi.create.draftRestoredBody')}

+
+ +
+
+ )} + {/* Coming from Stash indicator */} {fromStash && ( @@ -915,18 +1093,65 @@ export default function CreateListingPage() { key={index} className="relative aspect-square rounded-lg overflow-hidden bg-gray-800" > - {t('marketUi.create.productPhoto', + {index === 0 && ( + + {t('marketUi.create.cover')} + + )} +
+ {index > 0 ? ( + + ) : } +
+ {index > 0 && ( + + )} + {index < images.length - 1 && ( + + )} +
+
))} {images.length < 5 && ( @@ -1078,6 +1303,54 @@ export default function CreateListingPage() { + {/* Live preview — deliberately lightweight: no publish, no duplicate + event state. It catches missing/ugly listing content before submit. */} + + + {t('marketUi.create.previewTitle')} + {t('marketUi.create.previewNote')} + + + {!title.trim() && !description.trim() && images.length === 0 ? ( +

{t('marketUi.create.previewEmpty')}

+ ) : ( +
+
+ {images[0] ? ( + + ) : ( +
+ {listingType === 'wtb' ? '🔍' : '🛹'} +
+ )} +
+

+ {title.trim() || '—'} +

+
+ {previewCategory && {previewCategory}} + {previewCondition && {previewCondition}} + {size && {size}} +
+ {previewPrice &&

{previewPrice}

} + {summary.trim() &&

{summary}

} +
+
+ {description.trim() && ( +

{description}

+ )} + {images.length > 1 && ( +

{t('marketUi.create.photosNote')}

+ )} +
+ )} +
+
+ {/* Submit Button */} + ))} +
+ + {/* Filter Row */} +
+ {/* Listing Type */} +
+ + +
+ +
+ + {/* Trust Filter */} + + + + + + {t('marketUi.gearSwap.filterByTrust')} + + setTrustFilter('all')} + > + + {t('marketUi.gearSwap.allSellers')} + + setTrustFilter('verified')} + > + + {t('marketUi.gearSwap.verifiedOnly')} + + setTrustFilter('trusted')} + > + + {t('marketUi.gearSwap.trustedPlus')} + + setTrustFilter('following')} + disabled={!user} + > + + {t('marketUi.gearSwap.followingOnly')} + + + + +
+ + {/* Fulfilment */} + + + {/* Sort */} + +
+ + ); + return ( <>
@@ -288,162 +563,94 @@ export default function GearSwapPage() {
setSearchQuery(e.target.value)} - className="pl-10 bg-gray-900 border-gray-700 h-12 text-lg" + className="h-12 bg-gray-900 border-gray-700 pl-10 pr-11 text-lg" /> + {searchQuery && ( + + )}
- {/* Category Pills */} -
- {categoryOptions.map(({ value, label }) => ( - - ))} -
- - {/* Filter Row */} -
- {/* Condition / Listing Type */} -
- - -
- -
- - {/* Trust Filter */} - - - - - - {t('marketUi.gearSwap.filterByTrust')} - - setTrustFilter('all')} - > - - {t('marketUi.gearSwap.allSellers')} - - setTrustFilter('verified')} - > - - {t('marketUi.gearSwap.verifiedOnly')} - - setTrustFilter('trusted')} - > - - {t('marketUi.gearSwap.trustedPlus')} - - setTrustFilter('following')} - disabled={!user} - > - - {t('marketUi.gearSwap.followingOnly')} - - - - -
- - {/* Fulfilment */} - - - {/* Sort */} - + + + + + + {t('common.filter')} + + {t('marketUi.gearSwap.subtitle')} + +
{filterControls}
+ + + + +
+
+ +
{filterControls}
+ {activeFilters.length > 0 && ( +
+ + {t('marketUi.gearSwap.activeFilters')} + + {activeFilters.map((filter) => ( + + ))} + +
+ )} + {savedSearches.length > 0 && (
@@ -463,7 +670,7 @@ export default function GearSwapPage() { onClick={() => setSavedSearches((current) => current.filter((item) => item.id !== saved.id))} aria-label={t('marketplaceExtrasUi.deleteSearch')} title={t('marketplaceExtrasUi.deleteSearch')} - className="min-h-0 px-1.5 py-1 text-orange-300/60 hover:bg-red-500/20 hover:text-red-300" + className="flex h-10 w-10 items-center justify-center text-orange-300/60 hover:bg-red-500/20 hover:text-red-300" > @@ -475,7 +682,7 @@ export default function GearSwapPage() { {/* Results Count */} {!isLoading && (
-

+

{filteredItems.length === 1 ? t('marketUi.gearSwap.listingFound', { count: filteredItems.length }) : t('marketUi.gearSwap.listingsFound', { count: filteredItems.length })} @@ -495,6 +702,12 @@ export default function GearSwapPage() {

)} + {(sortBy === 'price_low' || sortBy === 'price_high') && ( +

+ {t('marketUi.gearSwap.priceRateNote')} +

+ )} + {/* Loading State */} {isLoading && (
@@ -535,20 +748,16 @@ export default function GearSwapPage() {

{t('marketUi.gearSwap.noneTitle')}

- {searchQuery || selectedCategory !== 'all' || trustFilter !== 'all' + {hasActiveFilters ? t('marketUi.gearSwap.noneFiltered') : t('marketUi.gearSwap.noneFirst')}

- {(searchQuery || selectedCategory !== 'all' || trustFilter !== 'all') && ( + {hasActiveFilters && ( +
+ )} + )}
diff --git a/src/pages/ListingDetailPage.tsx b/src/pages/ListingDetailPage.tsx index 0b8d2e3..74f271d 100644 --- a/src/pages/ListingDetailPage.tsx +++ b/src/pages/ListingDetailPage.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { ImageWithFallback } from '@/components/ImageWithFallback'; import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox'; import { TrustBadge } from '@/components/TrustBadge'; @@ -18,6 +19,7 @@ import { ClinkPayButton } from '@/components/ClinkPayButton'; import { OfferDialog } from '@/components/OfferDialog'; import { MarkSoldDialog } from '@/components/MarkSoldDialog'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; +import { ReportDialog } from '@/components/ReportDialog'; import { useMarketplaceItem } from '@/hooks/useMarketplace'; import { useMarkListingSold } from '@/hooks/useMarkListingSold'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -42,6 +44,7 @@ import { Handshake, Share2, ShieldAlert, + Flag, } from 'lucide-react'; import { useTranslation } from '@/contexts/language'; @@ -70,6 +73,8 @@ export default function ListingDetailPage() { const [showMarkSold, setShowMarkSold] = useState(false); const [showDelete, setShowDelete] = useState(false); const [showOffer, setShowOffer] = useState(false); + const [showReport, setShowReport] = useState(false); + const [mobileActionsOpen, setMobileActionsOpen] = useState(false); useSeoMeta({ title: item ? `${item.title} — SkateSpots Marketplace` : 'Listing — SkateSpots', @@ -147,7 +152,7 @@ export default function ListingDetailPage() { const handleShare = async () => { try { - await navigator.clipboard.writeText(window.location.href); + await navigator.clipboard.writeText(`https://skate-spots.org/listing/${item.pubkey}/${item.identifier}`); toast({ title: t('marketUi.listingDetail.linkCopied'), description: t('marketUi.listingDetail.linkCopiedBody') }); } catch { toast({ title: t('marketUi.listingDetail.copyFailed'), variant: 'destructive' }); @@ -169,7 +174,7 @@ export default function ListingDetailPage() { return ( <> -
+
{/* Description */} @@ -321,7 +340,7 @@ export default function ListingDetailPage() { {/* Actions */} {isOwnListing ? ( - + {isSold ? ( + {user && ( + + )} )} @@ -410,6 +439,90 @@ export default function ListingDetailPage() {
+ {/* Mobile sticky CTA: keeps the price and the next step visible while the + listing is scrolled. It jumps to the real actions rather than creating + a second set of payment/offer state. */} +
+
+
+

{priceDisplay}

+

+ {isSold ? t('marketplace.sold') : isWTB ? t('marketUi.gearSwap.wtb') : t('marketUi.listingDetail.viewOptions')} +

+
+ +
+
+ + {!isOwnListing && ( + + + + + + {t('marketUi.listingDetail.viewOptions')} + + {item.title} + +
+ {showSafetyNote ? ( +
+ {isNewSeller ? t('marketUi.card.newSeller') : t('marketTerms.highValueTitle')} — {t('marketTerms.safetyBody')} +
+ ) : null} + + {isValidNoffer(sellerNoffer) && ( + + )} + {user && item.priceType !== 'fixed' && ( + + )} + {user && ( + + )} +
+
+
+ )} + )} + + {!isOwnListing && ( + + )} ); } diff --git a/src/pages/MarketplaceHubPage.tsx b/src/pages/MarketplaceHubPage.tsx index b3ce397..ee01603 100644 --- a/src/pages/MarketplaceHubPage.tsx +++ b/src/pages/MarketplaceHubPage.tsx @@ -52,12 +52,12 @@ export default function MarketplaceHubPage() { onClick={() => setTab(id)} aria-pressed={active} className={cn( - 'flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wide transition-colors', + 'flex min-h-10 shrink-0 items-center gap-1 rounded-lg px-2.5 py-1.5 text-[11px] font-bold uppercase tracking-wide transition-colors sm:gap-1.5 sm:px-3 sm:text-xs', active ? 'bg-[#ccff00] text-black' : 'text-gray-400 hover:bg-white/5 hover:text-white', )} > - - {t(labelKey)} + + {t(labelKey)} ); })}