Skip to content
Merged
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
23 changes: 23 additions & 0 deletions src/client/features/audit/launch/AuditHistorySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,36 @@ export function AuditHistorySection({
projectId,
history,
isLoading,
loadFailed = false,
onDelete,
}: {
projectId: string;
history: Awaited<ReturnType<typeof getAuditHistory>>;
isLoading: boolean;
/** The history request failed. Distinct from having no audits: one means
* the list could not be read, the other that none have been run. */
loadFailed?: boolean;
onDelete: (auditId: string) => void;
}) {
// Before the empty state, always. A failed read also has zero rows, and
// "No audits yet" tells someone with a year of history that they have never
// run one -- then invites them to spend on a fresh audit to fix it.
if (loadFailed && history.length === 0) {
return (
<div className="flex items-center justify-center py-16">
<div className="space-y-2 text-center text-base-content/50">
<ScanSearch className="mx-auto size-12 opacity-30" />
<p className="text-sm font-medium">
Previous audits could not be loaded
</p>
<p className="text-sm">
Any audits you have run are still there — only this list failed.
</p>
</div>
</div>
);
}

if (history.length === 0 && !isLoading) {
return (
<div className="flex items-center justify-center py-16">
Expand Down
1 change: 1 addition & 0 deletions src/client/features/audit/launch/LaunchView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function LaunchContent({
projectId={projectId}
history={controller.historyQuery.data ?? []}
isLoading={controller.historyQuery.isLoading}
loadFailed={controller.historyQuery.isError}
onDelete={controller.deleteAudit}
/>
</AppPageShell>
Expand Down
131 changes: 80 additions & 51 deletions src/client/features/local-seo/LocalSeoPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { GbpWriteSection } from "./GbpWriteSection";
import { LocalReviewsSection } from "./LocalReviewsSection";
import { CitationTrackerSection } from "@/client/features/citations/CitationTrackerSection";
import { AppPageShell } from "@/client/components/AppPageShell";
import { resolveQueryState } from "@/client/components/state/queryState";
import { QueryStateBoundary } from "@/client/components/state/QueryStateBoundary";

const LOCAL_ANALYZE_PREVIEW: AnalyzePreviewItem[] = [
{
Expand Down Expand Up @@ -95,12 +97,23 @@ export function LocalSeoPage({
getBusinessProfile({ data: { projectId, keyword: runKeyword ?? "" } }),
});

const errorMessage = profileQuery.isError
? getStandardErrorMessage(profileQuery.error)
: null;
const profile =
profileQuery.data ??
(runKeyword == null ? cachedBusiness?.profile : undefined);

const profileState = resolveQueryState({
// `isFetching`, not `isPending`. This query is disabled until a lookup is
// authorized, and a disabled react-query stays pending forever — so
// `isPending` would report "loading" on a page nobody has run yet. What
// matters here is a request in flight with nothing to show behind it.
isPending: profileQuery.isFetching && profile === undefined,
isError: profileQuery.isError,
// Presence of a RESPONSE, never `profile.found`. A provider that looked and
// found nothing is a successful answer, and it carries spelling/city
// guidance the caller renders below; treating it as empty would replace
// that advice with a generic shrug.
rowCount: profile === undefined ? 0 : 1,
});
const profileKeyword = runKeyword ?? cachedBusiness?.keyword ?? businessGuess;
// Google's own identifiers first (stable across re-lookups of the same
// business), falling back to the lookup keyword only when neither is
Expand Down Expand Up @@ -201,10 +214,6 @@ export function LocalSeoPage({
</div>
</div>

{errorMessage ? (
<div className="alert alert-error text-sm">{errorMessage}</div>
) : null}

{runKeyword == null && !profile ? (
<AnalyzeDomainPrompt
domain={projectDomain}
Expand All @@ -224,51 +233,71 @@ export function LocalSeoPage({
}}
isBusy={profileQuery.isFetching}
/>
) : profile ? (
!profile.found ? (
<div className="card border border-base-300 bg-base-100">
<div className="card-body items-center py-12 text-sm text-base-content/60">
No Google Business Profile found for &ldquo;
{runKeyword ?? input}&rdquo;. Try adding the city or checking the
spelling.
) : (
<QueryStateBoundary
state={profileState}
loading={
<div className="card border border-base-300 bg-base-100">
<div className="card-body items-center gap-2 py-12 text-sm text-base-content/60">
<span className="loading loading-spinner loading-md" />
Looking up the business profile…
</div>
</div>
</div>
) : (
<>
<ProfileCard profile={profile} />
{audit ? (
<GbpAuditCard audit={audit} projectId={projectId} />
) : null}
<GbpWriteSection projectId={projectId} />
{profileKeyword ? (
<LocalReviewsSection
// Remounts on a new business so a stale taskId/reviews list
// from the previous lookup can never get silently attributed
// to this one -- both this section's own display and the
// audit's owner-response check depend on that not happening.
// handleReviewsLoaded tags what it stores with businessKey,
// which is the other half of that guarantee: see
// gbpReviewsScope.ts for why the remount alone isn't enough.
key={profileKeyword}
projectId={projectId}
keyword={profileKeyword}
onReviewsLoaded={handleReviewsLoaded}
/>
) : null}
<CitationTrackerSection
// Same remount-on-new-business reasoning as LocalReviewsSection
// above -- a stale authorized run for the previous business
// must never be silently reused for this one.
key={profileKeyword}
projectId={projectId}
businessName={profile.title ?? profileKeyword}
city={profile.city}
region={profile.region}
phone={profile.phone}
/>
</>
)
) : null}
}
errorMessage={getStandardErrorMessage(profileQuery.error)}
// Reachable only if the lookup resolves without returning a response
// object at all — distinct from the found-nothing case below, which is
// a successful answer.
emptyTitle="The lookup did not come back"
emptyBody="Nothing was returned for that search. Try running it again."
>
{profile ? (
!profile.found ? (
<div className="card border border-base-300 bg-base-100">
<div className="card-body items-center py-12 text-sm text-base-content/60">
No Google Business Profile found for &ldquo;
{runKeyword ?? input}&rdquo;. Try adding the city or checking
the spelling.
</div>
</div>
) : (
<>
<ProfileCard profile={profile} />
{audit ? (
<GbpAuditCard audit={audit} projectId={projectId} />
) : null}
<GbpWriteSection projectId={projectId} />
{profileKeyword ? (
<LocalReviewsSection
// Remounts on a new business so a stale taskId/reviews list
// from the previous lookup can never get silently attributed
// to this one -- both this section's own display and the
// audit's owner-response check depend on that not happening.
// handleReviewsLoaded tags what it stores with businessKey,
// which is the other half of that guarantee: see
// gbpReviewsScope.ts for why the remount alone isn't enough.
key={profileKeyword}
projectId={projectId}
keyword={profileKeyword}
onReviewsLoaded={handleReviewsLoaded}
/>
) : null}
<CitationTrackerSection
// Same remount-on-new-business reasoning as LocalReviewsSection
// above -- a stale authorized run for the previous business
// must never be silently reused for this one.
key={profileKeyword}
projectId={projectId}
businessName={profile.title ?? profileKeyword}
city={profile.city}
region={profile.region}
phone={profile.phone}
/>
</>
)
) : null}
</QueryStateBoundary>
)}
</AppPageShell>
);
}
Expand Down
36 changes: 33 additions & 3 deletions src/client/features/rank-tracking/RankTrackingDomainDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { captureClientEvent } from "@/client/lib/posthog";
import { FreePlanAlert } from "./FreePlanAlert";
import { RankTrackingDetailHeader } from "./RankTrackingDetailHeader";
import { RankTrackingOverview } from "./RankTrackingOverview";
import { InlineQueryError } from "@/client/components/InlineQueryError";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { RankTrackingTable } from "./RankTrackingTable";
import {
countMatrixRuns,
Expand Down Expand Up @@ -95,7 +97,14 @@ export function RankTrackingDomainDetail({
);
const [viewMode, setViewMode] = useState<"table" | "history">("table");

const { data: resultsData, isLoading: resultsLoading } = useQuery({
const {
data: resultsData,
isLoading: resultsLoading,
isError: resultsError,
error: resultsErrorValue,
refetch: refetchResults,
isFetching: resultsFetching,
} = useQuery({
queryKey: ["rankTrackingResults", projectId, config.id, comparePeriod],
queryFn: () =>
getLatestRankResults({
Expand All @@ -107,14 +116,23 @@ export function RankTrackingDomainDetail({

// Also feeds the History toggle: the matrix view only earns its tab once
// there are two checks to compare.
const { data: matrixCells, isLoading: matrixLoading } = useQuery({
const {
data: matrixCells,
isLoading: matrixLoading,
isError: matrixError,
} = useQuery({
queryKey: ["rankPositionMatrix", projectId, config.id, activeDevice],
queryFn: () =>
getRankPositionMatrix({
data: { projectId, configId: config.id, device: activeDevice },
}),
});
const historyAvailable = countMatrixRuns(matrixCells ?? []) >= 2;
// A failed matrix read used to make this false, which silently removed the
// History tab -- the evidence for hiding it was "the request did not come
// back", not "there is only one run". Keep the tab and let the view report
// its own failure rather than disappearing.
const historyAvailable =
matrixError || countMatrixRuns(matrixCells ?? []) >= 2;

const { data: costEstimate } = useQuery({
queryKey: ["rankTrackingCostEstimate", projectId, config.id],
Expand Down Expand Up @@ -337,12 +355,24 @@ export function RankTrackingDomainDetail({
<RankTrackingHistoryMatrix
cells={matrixCells ?? []}
isLoading={matrixLoading}
loadFailed={matrixError}
keywords={filtered.map((r) => ({
trackingKeywordId: r.trackingKeywordId,
keyword: r.keyword,
}))}
/>
</>
) : resultsError ? (
// Without this the failed read fell through to `rows = []`, and the
// table announced 'No rank data yet. Click "Check Now" to run your
// first check.' -- a first-run message shown to someone whose
// rankings simply failed to load, and an invitation to spend on a
// check they do not need.
<InlineQueryError
message={getStandardErrorMessage(resultsErrorValue)}
onRetry={() => void refetchResults()}
retrying={resultsFetching}
/>
) : (
<RankTrackingTable
key={defaultSortId}
Expand Down
16 changes: 16 additions & 0 deletions src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import type { RankPositionMatrixCell } from "@/serverFunctions/rank-tracking";
export function RankTrackingHistoryMatrix({
cells,
isLoading,
loadFailed = false,
keywords,
}: {
cells: RankPositionMatrixCell[];
isLoading: boolean;
/** The matrix request failed. Distinct from having no history: one means the
* timeline could not be read, the other that it has not been built yet. */
loadFailed?: boolean;
keywords: { trackingKeywordId: string; keyword: string }[];
}) {
const { runs, cellByKeyword } = useMemo(() => buildMatrix(cells), [cells]);
Expand All @@ -26,6 +30,18 @@ export function RankTrackingHistoryMatrix({
);
}

// Before emptiness, always. A failed read has no runs either, and saying
// "No history yet. Run a check" would invite a paid check to fix a request
// that simply did not come back.
if (loadFailed) {
return (
<div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55">
The ranking history could not be loaded, so the timeline is unavailable.
Your existing checks are unaffected.
</div>
);
}

if (runs.length === 0 || keywords.length === 0) {
return (
<div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55">
Expand Down
14 changes: 14 additions & 0 deletions src/routes/_project/p/$projectId/audit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
HttpStatusBadge,
StatusBadge,
} from "@/client/features/audit/shared";
import { InlineQueryError } from "@/client/components/InlineQueryError";
import { getStandardErrorMessage } from "@/client/lib/error-messages";

export const Route = createFileRoute<"/_project/p/$projectId/audit/">(
"/_project/p/$projectId/audit/",
Expand Down Expand Up @@ -171,6 +173,18 @@ function AuditDetail({
</div>
)}

{/* A completed audit whose results fail to load used to render nothing
at all: the condition required `resultsQuery.data`, so a rejected
request produced a blank page under a "complete" header, with no
error and no way to retry. Re-reading a finished audit is free. */}
{isComplete && resultsQuery.isError && (
<InlineQueryError
message={getStandardErrorMessage(resultsQuery.error)}
onRetry={() => void resultsQuery.refetch()}
retrying={resultsQuery.isFetching}
/>
)}

{isComplete && resultsQuery.data && (
<ResultsView
projectId={projectId}
Expand Down
30 changes: 29 additions & 1 deletion src/routes/_project/p/$projectId/rank-tracking/$configId.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { InlineQueryError } from "@/client/components/InlineQueryError";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getRankTrackingConfigs } from "@/serverFunctions/rank-tracking";
import { RankTrackingDomainDetail } from "@/client/features/rank-tracking/RankTrackingDomainDetail";
import { RankTrackingConfigModal } from "@/client/features/rank-tracking/RankTrackingConfigModal";
Expand All @@ -17,7 +19,14 @@ function RankTrackingConfigRoute() {
const queryClient = useQueryClient();
const [showConfigModal, setShowConfigModal] = useState(false);

const { data: configs, isLoading } = useQuery({
const {
data: configs,
isLoading,
isError,
error,
refetch,
isFetching,
} = useQuery({
queryKey: ["rankTrackingConfigs", projectId],
queryFn: () => getRankTrackingConfigs({ data: { projectId } }),
});
Expand All @@ -42,6 +51,25 @@ function RankTrackingConfigRoute() {

if (isLoading) return null;

// Before the not-found branch, always. A failed read leaves `configs`
// undefined, which made `config` null and told the user their domain does not
// exist -- reporting a deletion when nothing was read. The list is a free D1
// query, so retrying costs nothing.
if (isError) {
return (
<>
<InlineQueryError
message={getStandardErrorMessage(error)}
onRetry={() => void refetch()}
retrying={isFetching}
/>
<button className="btn btn-ghost btn-sm" onClick={handleBack}>
Back to domains
</button>
</>
);
}

if (!config) {
return (
<>
Expand Down
Loading