-
Notifications
You must be signed in to change notification settings - Fork 2
Add block filtering to store pages #184
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
ae4f740
605489f
ae95ed8
4c0b9fc
a518f94
b8cda89
292685f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
|
|
||
| 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 { | ||
|
|
@@ -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() { | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: Wait for block status before rendering stores
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}`) | ||
|
|
@@ -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> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 source: ['codex'] |
||
|
|
||
| const subtotal = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0) | ||
| const currency = items[0]?.currency || 'USD' | ||
|
|
||
|
|
@@ -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"> | ||
|
|
||
There was a problem hiding this comment.
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
blockedResolvedKeysurvives 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 clearsblockedOwnersbut leaves K marked as resolved. On A's return,isBlockCheckPendingis 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.source: ['codex']