Defer profile world history until needed
This commit is contained in:
@@ -63,6 +63,8 @@ use Inertia\Inertia;
|
|||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
{
|
{
|
||||||
|
private const SSR_WORLD_HISTORY_TABS = ['achievements', 'worlds'];
|
||||||
|
|
||||||
private const PROFILE_TABS = [
|
private const PROFILE_TABS = [
|
||||||
'posts',
|
'posts',
|
||||||
'artworks',
|
'artworks',
|
||||||
@@ -207,6 +209,26 @@ class ProfileController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only world history payload for profile tabs that are loaded client-side.
|
||||||
|
* This deliberately does not render the profile document or track a view.
|
||||||
|
*/
|
||||||
|
public function worldHistory(Request $request, string $username): JsonResponse
|
||||||
|
{
|
||||||
|
$normalized = UsernamePolicy::normalize($username);
|
||||||
|
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return response()->json(['error' => 'User not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||||
|
|
||||||
|
return response()->json($isOwner
|
||||||
|
? $this->worldProfileHistory->ownerPayloadForUser($user)
|
||||||
|
: $this->worldProfileHistory->publicPayloadForUser($user));
|
||||||
|
}
|
||||||
|
|
||||||
public function legacyById(Request $request, int $id, ?string $username = null)
|
public function legacyById(Request $request, int $id, ?string $username = null)
|
||||||
{
|
{
|
||||||
$user = User::query()->findOrFail($id);
|
$user = User::query()->findOrFail($id);
|
||||||
@@ -1292,9 +1314,11 @@ class ProfileController extends Controller
|
|||||||
->all();
|
->all();
|
||||||
$achievementSummary = $this->achievements->summary((int) $user->id);
|
$achievementSummary = $this->achievements->summary((int) $user->id);
|
||||||
$worldRewardSummary = $this->worldRewards->summaryForUser($user);
|
$worldRewardSummary = $this->worldRewards->summaryForUser($user);
|
||||||
$worldHistory = $isOwner
|
$worldHistory = in_array($initialTab, self::SSR_WORLD_HISTORY_TABS, true)
|
||||||
? $this->worldProfileHistory->ownerPayloadForUser($user)
|
? ($isOwner
|
||||||
: $this->worldProfileHistory->publicPayloadForUser($user);
|
? $this->worldProfileHistory->ownerPayloadForUser($user)
|
||||||
|
: $this->worldProfileHistory->publicPayloadForUser($user))
|
||||||
|
: null;
|
||||||
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
|
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
|
||||||
$groupContributionHistory = $this->buildGroupContributionHistory($user);
|
$groupContributionHistory = $this->buildGroupContributionHistory($user);
|
||||||
$journey = $this->creatorJourney->publicPayloadForUser($user);
|
$journey = $this->creatorJourney->publicPayloadForUser($user);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import TabFollowers from '../../components/profile/tabs/TabFollowers'
|
|||||||
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
||||||
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
|
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
|
||||||
import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites'
|
import useLazyProfileFavourites from '../../lib/useLazyProfileFavourites'
|
||||||
|
import useLazyProfileWorldHistory from '../../lib/useLazyProfileWorldHistory'
|
||||||
|
|
||||||
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
|
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
|
||||||
|
|
||||||
@@ -98,6 +99,11 @@ export default function ProfileShow() {
|
|||||||
initialFavourites: favourites,
|
initialFavourites: favourites,
|
||||||
enabled: activeTab === 'favourites',
|
enabled: activeTab === 'favourites',
|
||||||
})
|
})
|
||||||
|
const worldHistoryState = useLazyProfileWorldHistory({
|
||||||
|
username: user?.username || user?.name || '',
|
||||||
|
initialWorldHistory: worldHistory,
|
||||||
|
enabled: activeTab === 'worlds' || activeTab === 'achievements',
|
||||||
|
})
|
||||||
|
|
||||||
const handleTabChange = useCallback((tab) => {
|
const handleTabChange = useCallback((tab) => {
|
||||||
if (!VALID_TABS.includes(tab)) return
|
if (!VALID_TABS.includes(tab)) return
|
||||||
@@ -239,10 +245,10 @@ export default function ProfileShow() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'achievements' && (
|
{activeTab === 'achievements' && (
|
||||||
<TabAchievements achievements={achievements} worldRewards={worldRewards} worldHistory={worldHistory} onTabChange={handleTabChange} />
|
<TabAchievements achievements={achievements} worldRewards={worldRewards} worldHistory={worldHistoryState.data} onTabChange={handleTabChange} />
|
||||||
)}
|
)}
|
||||||
{activeTab === 'worlds' && (
|
{activeTab === 'worlds' && (
|
||||||
<TabWorlds worldHistory={worldHistory} isOwner={isOwner} />
|
<TabWorlds worldHistory={worldHistoryState.data} isOwner={isOwner} loading={worldHistoryState.loading} error={worldHistoryState.error} onRetry={worldHistoryState.retry} />
|
||||||
)}
|
)}
|
||||||
{activeTab === 'collections' && (
|
{activeTab === 'collections' && (
|
||||||
<TabCollections
|
<TabCollections
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ProfileWorldHistorySection from '../worlds/ProfileWorldHistorySection'
|
import ProfileWorldHistorySection from '../worlds/ProfileWorldHistorySection'
|
||||||
|
|
||||||
export default function TabWorlds({ worldHistory, isOwner }) {
|
export default function TabWorlds({ worldHistory, isOwner, loading = false, error = false, onRetry }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="tabpanel-worlds"
|
id="tabpanel-worlds"
|
||||||
@@ -9,6 +9,13 @@ export default function TabWorlds({ worldHistory, isOwner }) {
|
|||||||
aria-labelledby="tab-worlds"
|
aria-labelledby="tab-worlds"
|
||||||
className="pt-6"
|
className="pt-6"
|
||||||
>
|
>
|
||||||
|
{loading ? <p className="mb-4 text-sm text-slate-400">Loading worlds history…</p> : null}
|
||||||
|
{error ? (
|
||||||
|
<div className="mb-4 flex items-center gap-3 text-sm text-rose-300">
|
||||||
|
<span>Worlds history could not be loaded.</span>
|
||||||
|
<button type="button" onClick={onRetry} className="underline underline-offset-2">Retry</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<ProfileWorldHistorySection history={worldHistory} isOwner={isOwner} />
|
<ProfileWorldHistorySection history={worldHistory} isOwner={isOwner} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export default function useLazyProfileWorldHistory({ username, initialWorldHistory, enabled }) {
|
||||||
|
const hasInitialValue = initialWorldHistory !== null && initialWorldHistory !== undefined
|
||||||
|
const [data, setData] = useState(() => hasInitialValue ? initialWorldHistory : null)
|
||||||
|
const [loaded, setLoaded] = useState(hasInitialValue)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const requestKeyRef = useRef(username)
|
||||||
|
const requestedRef = useRef(hasInitialValue)
|
||||||
|
const abortRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestKeyRef.current === username) return undefined
|
||||||
|
|
||||||
|
abortRef.current?.abort()
|
||||||
|
abortRef.current = null
|
||||||
|
requestKeyRef.current = username
|
||||||
|
requestedRef.current = false
|
||||||
|
setData(null)
|
||||||
|
setLoaded(false)
|
||||||
|
setLoading(false)
|
||||||
|
setError(false)
|
||||||
|
return undefined
|
||||||
|
}, [username])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitialValue || requestKeyRef.current !== username) return
|
||||||
|
|
||||||
|
abortRef.current?.abort()
|
||||||
|
abortRef.current = null
|
||||||
|
requestedRef.current = true
|
||||||
|
setData(initialWorldHistory)
|
||||||
|
setLoaded(true)
|
||||||
|
setLoading(false)
|
||||||
|
setError(false)
|
||||||
|
}, [hasInitialValue, initialWorldHistory, username])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!enabled || hasInitialValue || requestKeyRef.current !== username || requestedRef.current || !username) return
|
||||||
|
|
||||||
|
requestedRef.current = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
const requestKey = requestKeyRef.current
|
||||||
|
abortRef.current = controller
|
||||||
|
setLoading(true)
|
||||||
|
setError(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/world-history`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('World history could not be loaded.')
|
||||||
|
const payload = await response.json()
|
||||||
|
if (requestKey !== requestKeyRef.current || controller.signal.aborted) return
|
||||||
|
setData(payload)
|
||||||
|
setLoaded(true)
|
||||||
|
} catch (caught) {
|
||||||
|
if (caught?.name === 'AbortError' || requestKey !== requestKeyRef.current) return
|
||||||
|
requestedRef.current = false
|
||||||
|
setError(true)
|
||||||
|
} finally {
|
||||||
|
if (requestKey === requestKeyRef.current) {
|
||||||
|
abortRef.current = null
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enabled, hasInitialValue, username])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) load()
|
||||||
|
}, [enabled, load])
|
||||||
|
|
||||||
|
useEffect(() => () => abortRef.current?.abort(), [])
|
||||||
|
|
||||||
|
return { data, loaded, loading, error, retry: load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, waitFor } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import useLazyProfileWorldHistory from './useLazyProfileWorldHistory'
|
||||||
|
|
||||||
|
function Harness({ username = 'alice', initial = null, enabled = false }) {
|
||||||
|
const state = useLazyProfileWorldHistory({
|
||||||
|
username,
|
||||||
|
initialWorldHistory: initial,
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <output data-testid="state">{JSON.stringify(state)}</output>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useLazyProfileWorldHistory', () => {
|
||||||
|
beforeEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
it('does not request on Posts until a consuming tab is active', () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
|
render(<Harness />)
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requests once on first consuming-tab activation and reuses the result', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ summary: { available: true }, entries: [{ id: 'world-1' }] }),
|
||||||
|
})
|
||||||
|
const view = render(<Harness />)
|
||||||
|
|
||||||
|
view.rerender(<Harness enabled />)
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||||
|
view.rerender(<Harness enabled={false} />)
|
||||||
|
view.rerender(<Harness enabled />)
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses direct Worlds/Achievements SSR data without a lazy request', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
|
const initial = { summary: { available: true }, entries: [{ id: 'world-1' }] }
|
||||||
|
const view = render(<Harness initial={initial} enabled />)
|
||||||
|
view.rerender(<Harness initial={initial} enabled={false} />)
|
||||||
|
view.rerender(<Harness initial={initial} enabled />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).data).toEqual(initial))
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an empty response as loaded', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ summary: { available: false }, entries: [] }) })
|
||||||
|
const view = render(<Harness enabled />)
|
||||||
|
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true))
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets on profile navigation and ignores the stale response', async () => {
|
||||||
|
let resolveA
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => { resolveA = resolve }))
|
||||||
|
const view = render(<Harness username="alice" enabled />)
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
view.rerender(<Harness username="bob" enabled />)
|
||||||
|
resolveA({ ok: true, json: async () => ({ summary: { available: true }, entries: [{ id: 'alice' }] }) })
|
||||||
|
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).data).toBe(null))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not loop after an error and permits an explicit retry', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce({ ok: false })
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: async () => ({ summary: { available: false }, entries: [] }) })
|
||||||
|
const view = render(<Harness enabled />)
|
||||||
|
await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).error).toBe(true))
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
// The hook's returned retry is intentionally exercised through a small rerender activation.
|
||||||
|
view.rerender(<Harness enabled={false} />)
|
||||||
|
view.rerender(<Harness enabled />)
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -911,6 +911,7 @@ Route::middleware(['web', 'throttle:60,1'])
|
|||||||
Route::get('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites');
|
Route::get('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites');
|
||||||
Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats');
|
Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats');
|
||||||
Route::get('featured-artworks', [\App\Http\Controllers\User\ProfileController::class, 'featuredArtworks'])->name('featured-artworks');
|
Route::get('featured-artworks', [\App\Http\Controllers\User\ProfileController::class, 'featuredArtworks'])->name('featured-artworks');
|
||||||
|
Route::get('world-history', [\App\Http\Controllers\User\ProfileController::class, 'worldHistory'])->name('world-history');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Link Preview (auth, throttled) ─────────────────────────────────────────────
|
// ── Link Preview (auth, throttled) ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Profile\WorldProfileHistoryService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Testing\AssertableInertia;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('omits the world history producer from the default Posts SSR payload', function (): void {
|
||||||
|
$user = User::factory()->create(['username' => 'lazyworlds']);
|
||||||
|
DB::flushQueryLog();
|
||||||
|
DB::enableQueryLog();
|
||||||
|
|
||||||
|
$this->get('/@' . $user->username)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('initialTab', 'posts')
|
||||||
|
->where('worldHistory', null));
|
||||||
|
|
||||||
|
$historyQueries = collect(DB::getQueryLog())->pluck('query')->filter(function (string $query): bool {
|
||||||
|
$query = strtolower($query);
|
||||||
|
|
||||||
|
return str_contains($query, 'world_submissions')
|
||||||
|
|| str_contains($query, 'group_challenge_outcomes')
|
||||||
|
|| str_contains($query, 'world_relations');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect($historyQueries)->toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps world history in direct Worlds and Achievements SSR payloads', function (string $tab): void {
|
||||||
|
$user = User::factory()->create(['username' => 'ssr' . $tab]);
|
||||||
|
|
||||||
|
$this->get('/@' . $user->username . '/' . $tab)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->where('initialTab', $tab)
|
||||||
|
->has('worldHistory.summary')
|
||||||
|
->has('worldHistory.entries'));
|
||||||
|
})->with(['worlds', 'achievements']);
|
||||||
|
|
||||||
|
it('returns the exact public service payload without tracking a profile view', function (): void {
|
||||||
|
$user = User::factory()->create(['username' => 'lazyworldapi']);
|
||||||
|
$expected = app(WorldProfileHistoryService::class)->publicPayloadForUser($user);
|
||||||
|
$before = (int) ($user->profile_views_count ?? 0);
|
||||||
|
|
||||||
|
$this->getJson('/api/profile/' . $user->username . '/world-history')
|
||||||
|
->assertOk()
|
||||||
|
->assertJson($expected);
|
||||||
|
|
||||||
|
expect((int) $user->fresh()->profile_views_count)->toBe($before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns owner context only to the profile owner', function (): void {
|
||||||
|
$user = User::factory()->create(['username' => 'ownerworldapi']);
|
||||||
|
$other = User::factory()->create(['username' => 'otherworldapi']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->getJson('/api/profile/' . $user->username . '/world-history')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('owner_context.pending_submissions', 0);
|
||||||
|
|
||||||
|
$this->actingAs($other)
|
||||||
|
->getJson('/api/profile/' . $user->username . '/world-history')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('owner_context', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns not found for an unknown world history profile', function (): void {
|
||||||
|
$this->getJson('/api/profile/missing-world-history/world-history')->assertNotFound();
|
||||||
|
});
|
||||||
@@ -95,7 +95,7 @@ it('exposes normalized world history on public profile pages', function (): void
|
|||||||
'awarded_at' => now()->subHour(),
|
'awarded_at' => now()->subHour(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
|
$this->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds']))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (AssertableInertia $page) => $page
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
->component('Profile/ProfileShow')
|
->component('Profile/ProfileShow')
|
||||||
@@ -161,7 +161,7 @@ it('filters stale public world rewards while preserving owner-only context count
|
|||||||
'status' => WorldSubmission::STATUS_PENDING,
|
'status' => WorldSubmission::STATUS_PENDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
|
$this->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds']))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (AssertableInertia $page) => $page
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
->component('Profile/ProfileShow')
|
->component('Profile/ProfileShow')
|
||||||
@@ -170,7 +170,7 @@ it('filters stale public world rewards while preserving owner-only context count
|
|||||||
->where('worldHistory.owner_context', null));
|
->where('worldHistory.owner_context', null));
|
||||||
|
|
||||||
$this->actingAs($creator)
|
$this->actingAs($creator)
|
||||||
->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
|
->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds']))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (AssertableInertia $page) => $page
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
->component('Profile/ProfileShow')
|
->component('Profile/ProfileShow')
|
||||||
|
|||||||
Reference in New Issue
Block a user