Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"root": true,
"extends": [
"next/core-web-vitals",
"plugin:@typescript-eslint/recommended"
Expand Down
46 changes: 45 additions & 1 deletion app/item/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { motion } from 'framer-motion'
import {
ArrowLeftIcon,
ShoppingCartIcon,
BuildingStorefrontIcon
BuildingStorefrontIcon,
NoSymbolIcon
} from '@heroicons/react/24/outline'
import { CheckIcon } from '@heroicons/react/24/solid'
import { Sidebar } from '@/components/layout/sidebar'
Expand All @@ -22,6 +23,7 @@ import { useSettingsStore } from '@/lib/store'
import { storeService } from '@/lib/services/store-service'
import { storeItemService } from '@/lib/services/store-item-service'
import { cartService } from '@/lib/services/cart-service'
import { useBlock } from '@/hooks/use-block'
import type { Store, StoreItem } from '@/lib/types'

function LoadingFallback() {
Expand Down Expand Up @@ -63,6 +65,9 @@ function ItemDetailContent() {
const [cartItemCount, setCartItemCount] = useState(0)
const addedToCartTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)

// Check if store owner is blocked
const { isBlocked: isOwnerBlocked, isOwnBlock, isLoading: isBlockLoading, toggleBlock } = useBlock(store?.ownerId ?? '', { resolveProvenance: true })

// Cleanup timeout on unmount
useEffect(() => {
return () => {
Expand Down Expand Up @@ -289,6 +294,45 @@ function ItemDetailContent() {
{/* Image Gallery */}
<ImageGallery images={images} alt={item.title} />

{/* Blocked Store Owner Banner */}
{isOwnerBlocked && (
<div className="mx-4 mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center gap-3">
<NoSymbolIcon className="h-6 w-6 text-red-500" />
<div className="flex-1">
<p className="font-medium text-red-700 dark:text-red-400">
{isOwnBlock ? 'You have blocked this store owner' : 'This store owner is blocked'}
</p>
<p className="text-sm text-red-600 dark:text-red-400/80">
{isOwnBlock
? "Items from this store won't appear in browse listings."
: "Blocked by a block list you follow. Items from this store won't appear in browse listings."}
</p>
</div>
{isOwnBlock ? (
<Button
variant="outline"
size="sm"
onClick={() => toggleBlock()}
disabled={isBlockLoading}
className="border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40"
>
Unblock
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => router.push('/settings?section=privacy')}
className="border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40"
>
Manage block lists
</Button>
)}
</div>
</div>
)}

{/* Item Info */}
<div className="p-4 space-y-4">
{/* Store Link */}
Expand Down
71 changes: 65 additions & 6 deletions app/store/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { logger } from '@/lib/logger';
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { motion } from 'framer-motion'
import {
Expand All @@ -21,6 +21,7 @@ import { useSdk } from '@/contexts/sdk-context'
import { useSettingsStore } from '@/lib/store'
import { storeService } from '@/lib/services/store-service'
import { storeReviewService } from '@/lib/services/store-review-service'
import { checkBlockedForAuthors } from '@/hooks/use-block'
import type { Store, StoreRatingSummary } from '@/lib/types'

export default function StoreBrowsePage() {
Expand All @@ -30,6 +31,8 @@ export default function StoreBrowsePage() {
const potatoMode = useSettingsStore((s) => s.potatoMode)
const [stores, setStores] = useState<Store[]>([])
const [storeRatings, setStoreRatings] = useState<Map<string, StoreRatingSummary>>(new Map())
const [blockedOwners, setBlockedOwners] = useState<Map<string, boolean>>(new Map())
const [blockedResolvedKey, setBlockedResolvedKey] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [hasStore, setHasStore] = useState(false)
Expand Down Expand Up @@ -79,13 +82,69 @@ export default function StoreBrowsePage() {
loadStores().catch((error) => logger.error(error))
}, [sdkReady])

// Filter stores by search query
const filteredStores = searchQuery
? stores.filter(store =>
// Identifies which (identity, store set) the blockedOwners state was resolved for
const blockCheckKey = user?.identityId && stores.length > 0
? `${user.identityId}:${stores.map(store => store.id).join(',')}`
: ''

// Check which store owners are blocked
useEffect(() => {
if (!user?.identityId || stores.length === 0) {
setBlockedOwners(new Map())
return
}

let cancelled = false
const identityId = user.identityId
const key = blockCheckKey
Comment on lines +91 to +99

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Invalidate a previously resolved key before rechecking it

blockedResolvedKey survives both the unauthenticated branch and the start of a new request. After identity A resolves key K, an A → guest → A transition with the same stores clears blockedOwners but leaves K marked as resolved. On A's return, isBlockCheckPending is therefore false throughout the replacement query, so every store—including A's blocked stores—renders as clickable. Clear the resolved key whenever this effect invalidates the map or begins a check, ensuring an old result cannot certify a later request with the same key.

Suggested change
useEffect(() => {
if (!user?.identityId || stores.length === 0) {
setBlockedOwners(new Map())
return
}
let cancelled = false
const identityId = user.identityId
const key = blockCheckKey
useEffect(() => {
setBlockedResolvedKey('')
if (!user?.identityId || stores.length === 0) {
setBlockedOwners(new Map())
return
}
let cancelled = false
const identityId = user.identityId
const key = blockCheckKey

source: ['codex']


const checkBlockedOwners = async () => {
const ownerIds = stores.map(store => store.ownerId)
const blocked = await checkBlockedForAuthors(identityId, ownerIds)
if (!cancelled) {
setBlockedOwners(blocked)
setBlockedResolvedKey(key)
}
}

checkBlockedOwners().catch((error) => {
logger.error('Failed to check blocked store owners:', error)
// Fail open: show stores rather than blocking the page forever
if (!cancelled) {
setBlockedOwners(new Map())
setBlockedResolvedKey(key)
}
})

return () => {
cancelled = true
}
}, [user?.identityId, stores, blockCheckKey])

// Keep the list in its loading state until block status resolves for the
// current identity and store set, so blocked stores never flash as clickable.
// Guests have no blocks, so they skip this entirely.
const isBlockCheckPending = blockCheckKey !== '' && blockedResolvedKey !== blockCheckKey

// Filter stores by search query and block status
const filteredStores = useMemo(() => {
let filtered = stores

// Filter out stores owned by blocked users
if (blockedOwners.size > 0) {
filtered = filtered.filter(store => !blockedOwners.get(store.ownerId))
}
Comment on lines +133 to +136

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Wait for block status before rendering stores

blockedOwners is initially empty, and the independent block-status effect does not participate in isLoading. Consequently, when ratings finish before the block query—or the authenticated identity changes after the list is visible—these lines treat unresolved status as if no owners were blocked and render every store as clickable. Keep the list loading or hidden until the block check for the current identity and store set completes.

source: ['codex']


// Apply search filter
if (searchQuery) {
filtered = filtered.filter(store =>
store.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
store.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
: stores
}

return filtered
}, [stores, blockedOwners, searchQuery])

const handleStoreClick = (storeId: string) => {
router.push(`/store/view?id=${storeId}`)
Expand Down Expand Up @@ -149,7 +208,7 @@ export default function StoreBrowsePage() {

{/* Store List */}
<div className="divide-y divide-gray-200 dark:divide-gray-800">
{isLoading ? (
{isLoading || isBlockCheckPending ? (
<div className="p-8 text-center">
<Spinner className="mx-auto mb-4" />
<p className="text-gray-500">Loading stores...</p>
Expand Down
44 changes: 44 additions & 0 deletions app/store/view/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
MagnifyingGlassIcon,
MapPinIcon,
ShoppingCartIcon,
NoSymbolIcon,
XMarkIcon
} from '@heroicons/react/24/outline'
import { Sidebar } from '@/components/layout/sidebar'
Expand All @@ -28,6 +29,7 @@ import { storeItemService } from '@/lib/services/store-item-service'
import { storeReviewService } from '@/lib/services/store-review-service'
import { cartService } from '@/lib/services/cart-service'
import { parseStorePolicies } from '@/lib/utils/policies'
import { useBlock } from '@/hooks/use-block'
import { saveStoreViewCache, loadStoreViewCache } from '@/lib/caches/store-view-cache'
import type { Store, StoreItem, StoreReview, StoreRatingSummary, StorePolicy } from '@/lib/types'

Expand Down Expand Up @@ -85,6 +87,9 @@ function StoreDetailContent() {
const restoredFromCache = useRef(false)
const pendingScrollY = useRef<number | null>(null)

// Check if store owner is blocked
const { isBlocked: isOwnerBlocked, isOwnBlock, isLoading: isBlockLoading, toggleBlock } = useBlock(store?.ownerId ?? '', { resolveProvenance: true })

// Subscribe to cart changes
useEffect(() => {
const unsubscribe = cartService.subscribe(() => {
Expand Down Expand Up @@ -462,6 +467,45 @@ function StoreDetailContent() {
</div>
</div>

{/* Blocked Store Banner */}
{isOwnerBlocked && (
<div className="mx-4 mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center gap-3">
<NoSymbolIcon className="h-6 w-6 text-red-500" />
<div className="flex-1">
<p className="font-medium text-red-700 dark:text-red-400">
{isOwnBlock ? 'You have blocked this store owner' : 'This store owner is blocked'}
</p>
<p className="text-sm text-red-600 dark:text-red-400/80">
{isOwnBlock
? "This store's products won't appear in browse listings."
: "Blocked by a block list you follow. This store's products won't appear in browse listings."}
</p>
</div>
{isOwnBlock ? (
<Button
variant="outline"
size="sm"
onClick={() => toggleBlock()}
disabled={isBlockLoading}
className="border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40"
>
Unblock
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => router.push('/settings?section=privacy')}
className="border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40"
>
Manage block lists
</Button>
)}
</div>
</div>
)}

{/* Encryption key warning */}
{sellerHasEncryptionKey === false && (
<div className="mx-4 my-3 flex items-start gap-3 rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-950/50">
Expand Down
20 changes: 19 additions & 1 deletion components/store/cart-store-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
import { forwardRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useRouter } from 'next/navigation'
import { BuildingStorefrontIcon } from '@heroicons/react/24/outline'
import { BuildingStorefrontIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { CartItemRow } from './cart-item-row'
import { Button } from '@/components/ui/button'
import { formatPrice } from '@/lib/utils/format'
import { cartService } from '@/lib/services/cart-service'
import { useBlock } from '@/hooks/use-block'
import type { CartItem, Store } from '@/lib/types'

interface CartStoreSectionProps {
Expand All @@ -21,6 +22,9 @@ export const CartStoreSection = forwardRef<HTMLDivElement, CartStoreSectionProps
function CartStoreSection({ storeId, store, items, onRemoveAll }, ref) {
const router = useRouter()

// Check if store owner is blocked
const { isBlocked: isOwnerBlocked, isOwnBlock } = useBlock(store?.ownerId ?? '')
Comment on lines +25 to +26

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Batch cart-owner block checks before rendering sections

Each cart store independently mounts useBlock. Without a confirmed result, the hook calls getBlockProvenance, which performs a direct-block query and then one query for every followed block list. A cart containing S stores while the viewer follows F block lists can therefore issue approximately S × (F + 1) Platform reads, even when the merged bloom filter proves that most owners are not blocked. Batch all cart owner IDs once with checkBlockedForAuthors in the cart page and pass the prefetched status into each section; checkBlockedBatch will seed the confirmed provenance cache consumed by these hooks.

source: ['codex']


const subtotal = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0)
const currency = items[0]?.currency || 'USD'

Expand Down Expand Up @@ -71,6 +75,20 @@ export const CartStoreSection = forwardRef<HTMLDivElement, CartStoreSectionProps
</button>
</div>

{/* Blocked Store Owner Warning */}
{isOwnerBlocked && (
<div className="mx-4 mt-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<div className="flex items-center gap-2">
<ExclamationTriangleIcon className="h-5 w-5 text-amber-500 flex-shrink-0" />
<p className="text-sm text-amber-700 dark:text-amber-400">
{isOwnBlock
? 'You have blocked this store owner. Consider removing these items.'
: 'This store owner is blocked by a block list you follow. Consider removing these items.'}
</p>
</div>
</div>
)}

{/* Items */}
<div className="divide-y divide-gray-100 dark:divide-gray-900">
<AnimatePresence mode="popLayout">
Expand Down
Loading
Loading