From bdceb8eae76f60fd94e8b5ebe748aacb866457ae Mon Sep 17 00:00:00 2001 From: Sourov Biswas Date: Mon, 21 Sep 2026 12:47:35 +0600 Subject: [PATCH 1/2] Say CarbonImmutable where the models already hand one back The app calls Date::use(CarbonImmutable::class), so every date attribute is immutable at runtime. The docblocks still promised a mutable Carbon, which told larastan and the editor that ->addDay() would mutate the attribute when it quietly does nothing. Co-Authored-By: Claude Opus 5 --- app/Models/Membership.php | 6 +++--- app/Models/OrderStatusSetting.php | 6 +++--- app/Models/Organization.php | 8 ++++---- app/Models/OrganizationInvitation.php | 10 +++++----- app/Models/Shop.php | 8 ++++---- app/Models/User.php | 10 +++++----- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/app/Models/Membership.php b/app/Models/Membership.php index 81cffbb..74cbde3 100644 --- a/app/Models/Membership.php +++ b/app/Models/Membership.php @@ -3,18 +3,18 @@ namespace App\Models; use App\Enums\OrganizationRole; +use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\Pivot; -use Illuminate\Support\Carbon; /** * @property int $id * @property int $organization_id * @property int $user_id * @property OrganizationRole $role - * @property Carbon|null $created_at - * @property Carbon|null $updated_at + * @property CarbonImmutable|null $created_at + * @property CarbonImmutable|null $updated_at * @property-read Organization $organization * @property-read User $user */ diff --git a/app/Models/OrderStatusSetting.php b/app/Models/OrderStatusSetting.php index 1da9e94..90ccb90 100644 --- a/app/Models/OrderStatusSetting.php +++ b/app/Models/OrderStatusSetting.php @@ -2,12 +2,12 @@ namespace App\Models; +use Carbon\CarbonImmutable; use Database\Factories\OrderStatusSettingFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Support\Carbon; /** * What an organization has decided one WooCommerce order status means. @@ -22,8 +22,8 @@ * @property string $status * @property string|null $label * @property bool $counts_as_revenue - * @property Carbon|null $created_at - * @property Carbon|null $updated_at + * @property CarbonImmutable|null $created_at + * @property CarbonImmutable|null $updated_at * @property-read Organization $organization */ #[Fillable(['status', 'label', 'counts_as_revenue'])] diff --git a/app/Models/Organization.php b/app/Models/Organization.php index ec7d012..a39c20f 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -4,6 +4,7 @@ use App\Concerns\GeneratesUniqueOrganizationSlugs; use App\Enums\OrganizationRole; +use Carbon\CarbonImmutable; use Database\Factories\OrganizationFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Collection; @@ -13,16 +14,15 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Support\Carbon; /** * @property int $id * @property string $name * @property string $slug * @property string|null $timezone - * @property Carbon|null $created_at - * @property Carbon|null $updated_at - * @property Carbon|null $deleted_at + * @property CarbonImmutable|null $created_at + * @property CarbonImmutable|null $updated_at + * @property CarbonImmutable|null $deleted_at * @property-read Collection $invitations * @property-read Collection $memberships * @property-read Collection $members diff --git a/app/Models/OrganizationInvitation.php b/app/Models/OrganizationInvitation.php index 231cfdd..f1fca65 100644 --- a/app/Models/OrganizationInvitation.php +++ b/app/Models/OrganizationInvitation.php @@ -3,13 +3,13 @@ namespace App\Models; use App\Enums\OrganizationRole; +use Carbon\CarbonImmutable; use Database\Factories\OrganizationInvitationFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Support\Carbon; use Illuminate\Support\Str; /** @@ -19,10 +19,10 @@ * @property string $email * @property OrganizationRole $role * @property int $invited_by - * @property Carbon|null $expires_at - * @property Carbon|null $accepted_at - * @property Carbon|null $created_at - * @property Carbon|null $updated_at + * @property CarbonImmutable|null $expires_at + * @property CarbonImmutable|null $accepted_at + * @property CarbonImmutable|null $created_at + * @property CarbonImmutable|null $updated_at * @property-read Organization $organization * @property-read User $inviter */ diff --git a/app/Models/Shop.php b/app/Models/Shop.php index 3f7b6e4..d330f94 100644 --- a/app/Models/Shop.php +++ b/app/Models/Shop.php @@ -5,6 +5,7 @@ use App\Data\ShopConnectionResult; use App\Enums\ShopConnectionStatus; use App\Enums\ShopPlatform; +use Carbon\CarbonImmutable; use Database\Factories\ShopFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -15,7 +16,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; -use Illuminate\Support\Carbon; use Illuminate\Support\Str; /** @@ -29,10 +29,10 @@ * @property string $consumer_secret * @property ShopConnectionStatus $connection_status * @property string|null $connection_message - * @property Carbon|null $connection_checked_at + * @property CarbonImmutable|null $connection_checked_at * @property int|null $connection_response_time_ms - * @property Carbon|null $created_at - * @property Carbon|null $updated_at + * @property CarbonImmutable|null $created_at + * @property CarbonImmutable|null $updated_at * @property-read Organization $organization * @property-read int|null $orders_count * @property-read ShopSyncState|null $syncState diff --git a/app/Models/User.php b/app/Models/User.php index 04d849e..58fd594 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Concerns\HasOrganizations; +use Carbon\CarbonImmutable; use Database\Factories\UserFactory; use Filament\Models\Contracts\FilamentUser; use Filament\Panel; @@ -13,7 +14,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Illuminate\Support\Carbon; use Laravel\Fortify\Contracts\PasskeyUser; use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -22,15 +22,15 @@ * @property int $id * @property string $name * @property string $email - * @property Carbon|null $email_verified_at + * @property CarbonImmutable|null $email_verified_at * @property string $password * @property string|null $two_factor_secret * @property string|null $two_factor_recovery_codes - * @property Carbon|null $two_factor_confirmed_at + * @property CarbonImmutable|null $two_factor_confirmed_at * @property string|null $remember_token * @property int|null $current_organization_id - * @property Carbon|null $created_at - * @property Carbon|null $updated_at + * @property CarbonImmutable|null $created_at + * @property CarbonImmutable|null $updated_at * @property-read Organization|null $currentOrganization * @property-read Collection $ownedOrganizations * @property-read Collection $organizationMemberships From 8f689d9f40ffc9667487fe75af61ae7053cbab3c Mon Sep 17 00:00:00 2001 From: Sourov Biswas Date: Mon, 21 Sep 2026 12:47:35 +0600 Subject: [PATCH 2/2] Share the pagination arrow and the debounce with the other apps Both were written twice over: the arrow is the same component the product apps render, and the shop search kept its own timeout where a hook already existed. The search now settles a value rather than juggling a ref, and only visits when the settled term disagrees with the server. Co-Authored-By: Claude Opus 5 --- resources/js/components/pagination-arrow.tsx | 52 ++++++++++++ resources/js/hooks/use-debounced-value.ts | 19 +++++ resources/js/pages/shops/index.tsx | 84 ++++++++------------ 3 files changed, 102 insertions(+), 53 deletions(-) create mode 100644 resources/js/components/pagination-arrow.tsx create mode 100644 resources/js/hooks/use-debounced-value.ts diff --git a/resources/js/components/pagination-arrow.tsx b/resources/js/components/pagination-arrow.tsx new file mode 100644 index 0000000..4251cac --- /dev/null +++ b/resources/js/components/pagination-arrow.tsx @@ -0,0 +1,52 @@ +import { Link } from '@inertiajs/react'; +import type { LucideIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +/** + * One step of a paginated list, as an icon button. + * + * The paginator hands back a null url at either end, which is the disabled + * state: the control stays in place so the row of arrows does not reflow on + * the first and last page. + */ +export default function PaginationArrow({ + href, + label, + icon: Icon, + test, +}: { + href: string | null; + label: string; + icon: LucideIcon; + test: string; +}) { + if (!href) { + return ( + + ); + } + + return ( + + ); +} diff --git a/resources/js/hooks/use-debounced-value.ts b/resources/js/hooks/use-debounced-value.ts new file mode 100644 index 0000000..9a4ed2d --- /dev/null +++ b/resources/js/hooks/use-debounced-value.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +/** + * Hold back a rapidly changing value until it settles. + * + * The list filters use this so a request goes out once the typing stops + * rather than once per keystroke. + */ +export function useDebouncedValue(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timeout = window.setTimeout(() => setDebounced(value), delay); + + return () => window.clearTimeout(timeout); + }, [value, delay]); + + return debounced; +} diff --git a/resources/js/pages/shops/index.tsx b/resources/js/pages/shops/index.tsx index 13e5b78..ac74ee3 100644 --- a/resources/js/pages/shops/index.tsx +++ b/resources/js/pages/shops/index.tsx @@ -1,5 +1,4 @@ import { Head, Link, router, usePage } from '@inertiajs/react'; -import type { LucideIcon } from 'lucide-react'; import { ChevronLeft, ChevronRight, @@ -15,7 +14,7 @@ import { TriangleAlert, X, } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import DeleteShopModal from '@/components/delete-shop-modal'; import ShopFormModal from '@/components/shop-form-modal'; import StatusBadge, { @@ -23,6 +22,8 @@ import StatusBadge, { StatusTooltip, } from '@/components/status-badge'; import { Badge } from '@/components/ui/badge'; +import PaginationArrow from '@/components/pagination-arrow'; +import { useDebouncedValue } from '@/hooks/use-debounced-value'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -76,13 +77,34 @@ export default function ShopsIndex({ const [deleteOpen, setDeleteOpen] = useState(false); const [shopToDelete, setShopToDelete] = useState(null); const [testingShopId, setTestingShopId] = useState(null); - const searchTimeout = useRef>(undefined); + const debouncedSearch = useDebouncedValue(search); + const applySearch = (value: string) => { + if (!currentOrganization) { + return; + } + + router.get( + shopsIndex(currentOrganization.slug).url, + value ? { search: value } : {}, + { preserveState: true, preserveScroll: true, replace: true }, + ); + }; + + /** + * The server value is the source of truth, so a visit only fires when the + * settled input disagrees with it. That skips the pointless request on + * mount and converges rather than looping once our own response lands. + */ useEffect(() => { - const timeout = searchTimeout; + const term = debouncedSearch.trim(); - return () => clearTimeout(timeout.current); - }, []); + if (term === filters.search) { + return; + } + + applySearch(term); + }, [debouncedSearch, filters.search]); if (!currentOrganization) { return null; @@ -92,25 +114,7 @@ export default function ShopsIndex({ const canManageShops = permissions.canUpdateShop || permissions.canDeleteShop; - const applySearch = (value: string) => { - router.get( - shopsIndex(currentOrganization.slug).url, - value ? { search: value } : {}, - { preserveState: true, preserveScroll: true, replace: true }, - ); - }; - - const handleSearchChange = (value: string) => { - setSearch(value); - clearTimeout(searchTimeout.current); - searchTimeout.current = setTimeout( - () => applySearch(value.trim()), - 300, - ); - }; - const clearFilters = () => { - clearTimeout(searchTimeout.current); setSearch(''); applySearch(''); }; @@ -201,7 +205,7 @@ export default function ShopsIndex({ type="search" value={search} onChange={(event) => - handleSearchChange(event.target.value) + setSearch(event.target.value) } placeholder="Search by name or URL" aria-label="Search shops" @@ -485,6 +489,7 @@ export default function ShopsIndex({ href={shops.prev_page_url} label="Previous page" icon={ChevronLeft} + test="pagination-previous" /> {shops.links .slice(1, -1) @@ -527,6 +532,7 @@ export default function ShopsIndex({ href={shops.next_page_url} label="Next page" icon={ChevronRight} + test="pagination-next" /> ) : null} @@ -553,34 +559,6 @@ export default function ShopsIndex({ ); } -function PaginationArrow({ - href, - label, - icon: Icon, -}: { - href: string | null; - label: string; - icon: LucideIcon; -}) { - if (!href) { - return ( - - ); - } - - return ( - - ); -} - ShopsIndex.layout = (props: { currentOrganization?: { slug: string } | null; }) => ({