Put the group description under the review-card sections and tint the card from the logo #83

Merged
an.di merged 4 commits from adilallo/fix/CR-218-review-preview-card into main 2026-09-29 21:17:26 +00:00
5 changed files with 198 additions and 26 deletions
Showing only changes of commit b12de611f7 - Show all commits

No files matched your search

@@ -1,23 +1,8 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import { dominantColorFromImageUrl } from "../../../../lib/create/previewSurfaceColor";
dominantColorFromImageUrl, import { resolvePreviewSurfaceFallback } from "../utils/resolvePreviewSurfaceFallback";
isPreviewSurfaceColor,
randomPreviewSurfaceColor,
} from "../../../../lib/create/previewSurfaceColor";
function storedOrRandom(
storedFallback: string | undefined,
persistFallback: (_color: string) => void,
): string {
if (storedFallback && isPreviewSurfaceColor(storedFallback)) {
return storedFallback;
}
const next = randomPreviewSurfaceColor();
persistFallback(next);
return next;
}
/** /**
* Fill for the review preview card. An uploaded logo contributes its most * Fill for the review preview card. An uploaded logo contributes its most
@@ -27,7 +12,7 @@ function storedOrRandom(
* *
* @param imageUrl Uploaded logo URL, or `null` when the user skipped the photo. * @param imageUrl Uploaded logo URL, or `null` when the user skipped the photo.
* @param storedFallback Draft `previewSurfaceColor`, if already picked. * @param storedFallback Draft `previewSurfaceColor`, if already picked.
* @param persistFallback Writes the fallback onto the draft once. * @param persistFallback Writes the fallback onto React state once.
* @returns A CSS color, or `undefined` before the client picks one. * @returns A CSS color, or `undefined` before the client picks one.
* *
* @example * @example
@@ -42,15 +27,23 @@ export function usePreviewSurfaceColor(
useEffect(() => { useEffect(() => {
if (!imageUrl) { if (!imageUrl) {
const next = resolvePreviewSurfaceFallback(storedFallback);
persistFallback(next);
// eslint-disable-next-line react-hooks/set-state-in-effect -- random fill is client-only; first paint stays on the fallback class // eslint-disable-next-line react-hooks/set-state-in-effect -- random fill is client-only; first paint stays on the fallback class
setColor(storedOrRandom(storedFallback, persistFallback)); setColor(next);
return; return;
} }
let cancelled = false; let cancelled = false;
void dominantColorFromImageUrl(imageUrl).then((next) => { void dominantColorFromImageUrl(imageUrl).then((extracted) => {
if (cancelled) return; if (cancelled) return;
setColor(next ?? storedOrRandom(storedFallback, persistFallback)); if (extracted) {
setColor(extracted);
return;
}
const next = resolvePreviewSurfaceFallback(storedFallback);
persistFallback(next);
setColor(next);
}); });
return () => { return () => {
@@ -34,6 +34,10 @@ import {
vectorMarkPath, vectorMarkPath,
} from "../../../../../lib/assetUtils"; } from "../../../../../lib/assetUtils";
import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor"; import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor";
import {
persistDraftPreviewSurfaceColor,
readDraftPreviewSurfaceColor,
} from "../../utils/resolvePreviewSurfaceFallback";
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [ const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
"coreValues", "coreValues",
@@ -247,12 +251,19 @@ export function FinalReviewScreen({
: undefined; : undefined;
const persistFallback = useCallback( const persistFallback = useCallback(
(color: string) => { (color: string) => {
replaceState((prev) => replaceState((prev) => {
prev.previewSurfaceColor && persistDraftPreviewSurfaceColor(color, prev);
const keep =
(prev.previewSurfaceColor &&
isPreviewSurfaceColor(prev.previewSurfaceColor) isPreviewSurfaceColor(prev.previewSurfaceColor)
? prev.previewSurfaceColor
: undefined) ??
readDraftPreviewSurfaceColor() ??
color;
return prev.previewSurfaceColor === keep
? prev ? prev
: { ...prev, previewSurfaceColor: color }, : { ...prev, previewSurfaceColor: keep };
); });
}, },
[replaceState], [replaceState],
); );
@@ -0,0 +1,64 @@
import type { CreateFlowState } from "../types";
import {
isPreviewSurfaceColor,
randomPreviewSurfaceColor,
} from "../../../../lib/create/previewSurfaceColor";
import {
readAnonymousCreateFlowState,
writeAnonymousCreateFlowState,
} from "./anonymousDraftStorage";
function validPreviewSurfaceColor(
value: string | undefined,
): string | undefined {
return typeof value === "string" && isPreviewSurfaceColor(value)
? value
: undefined;
}
/** Existing draft fill from React state or localStorage, if it is a stored hsl. */
export function readDraftPreviewSurfaceColor(
storedFallback?: string,
): string | undefined {
return (
validPreviewSurfaceColor(storedFallback) ??
validPreviewSurfaceColor(
readAnonymousCreateFlowState().previewSurfaceColor,
)
);
}
/**
* Keep a fallback fill on the anonymous draft immediately. The provider’s
* first mirrored write is skipped, so waiting on React state loses the color
* across reload. `state` is merged so a write before hydrate does not drop
* other draft fields already in memory.
*/
export function persistDraftPreviewSurfaceColor(
color: string,
state: CreateFlowState = {},
): void {
const next = validPreviewSurfaceColor(color);
if (!next) return;
const current = readAnonymousCreateFlowState();
const stored = validPreviewSurfaceColor(current.previewSurfaceColor);
writeAnonymousCreateFlowState({
...current,
...state,
previewSurfaceColor: stored ?? next,
});
}
/**
* Reuse the draft fill when one exists; otherwise pick one and write it now.
* Reads localStorage itself so a child effect can run before context hydrates.
*/
export function resolvePreviewSurfaceFallback(
storedFallback?: string,
): string {
const existing = readDraftPreviewSurfaceColor(storedFallback);
if (existing) return existing;
const color = randomPreviewSurfaceColor();
persistDraftPreviewSurfaceColor(color);
return color;
}
@@ -1,10 +1,11 @@
import { useEffect, useLayoutEffect } from "react"; import { useEffect, useLayoutEffect } from "react";
import { describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { waitFor } from "@testing-library/react"; import { waitFor } from "@testing-library/react";
import { renderWithProviders as render, screen } from "../utils/test-utils"; import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen"; import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext"; import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { CREATE_FLOW_ANONYMOUS_KEY } from "../../app/(app)/create/utils/anonymousDraftStorage";
import { import {
dominantColorFromImageUrl, dominantColorFromImageUrl,
randomPreviewSurfaceColor, randomPreviewSurfaceColor,
@@ -60,6 +61,10 @@ function FinalReviewWithStateProbe({
} }
describe("Final review preview surface", () => { describe("Final review preview surface", () => {
beforeEach(() => {
window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY);
vi.mocked(randomPreviewSurfaceColor).mockClear();
});
it("fills the card from the uploaded logo’s most common color", async () => { it("fills the card from the uploaded logo’s most common color", async () => {
render(<FinalReviewWithAvatar />); render(<FinalReviewWithAvatar />);
@@ -95,6 +100,34 @@ describe("Final review preview surface", () => {
expect(vi.mocked(randomPreviewSurfaceColor)).toHaveBeenCalled(); expect(vi.mocked(randomPreviewSurfaceColor)).toHaveBeenCalled();
}); });
it("reuses a fallback saved only in localStorage across a fresh mount", async () => {
window.localStorage.setItem(
CREATE_FLOW_ANONYMOUS_KEY,
JSON.stringify({
title: "Oak Park Commons",
communityContext: "Local mutual aid.",
previewSurfaceColor: "hsl(200, 60%, 80%)",
}),
);
let latest: CreateFlowState = {};
render(
<FinalReviewWithStateProbe
initial={{
title: "Oak Park Commons",
communityContext: "Local mutual aid.",
}}
onState={(state) => {
latest = state;
}}
/>,
);
await waitFor(() => {
expect(latest.previewSurfaceColor).toBe("hsl(200, 60%, 80%)");
});
expect(vi.mocked(randomPreviewSurfaceColor)).not.toHaveBeenCalled();
});
it("reuses the stored fallback instead of picking a new color", async () => { it("reuses the stored fallback instead of picking a new color", async () => {
vi.mocked(randomPreviewSurfaceColor).mockClear(); vi.mocked(randomPreviewSurfaceColor).mockClear();
render( render(
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CREATE_FLOW_ANONYMOUS_KEY } from "../../app/(app)/create/utils/anonymousDraftStorage";
import {
persistDraftPreviewSurfaceColor,
readDraftPreviewSurfaceColor,
resolvePreviewSurfaceFallback,
} from "../../app/(app)/create/utils/resolvePreviewSurfaceFallback";
import { randomPreviewSurfaceColor } from "../../lib/create/previewSurfaceColor";
vi.mock("../../lib/create/previewSurfaceColor", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../lib/create/previewSurfaceColor")
>();
return {
...actual,
randomPreviewSurfaceColor: vi.fn(() => "hsl(12, 50%, 80%)"),
};
});
describe("resolvePreviewSurfaceFallback", () => {
beforeEach(() => {
window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY);
vi.mocked(randomPreviewSurfaceColor).mockClear();
});
it("reuses a valid fill already on React state", () => {
expect(resolvePreviewSurfaceFallback("hsl(200, 60%, 80%)")).toBe(
"hsl(200, 60%, 80%)",
);
expect(randomPreviewSurfaceColor).not.toHaveBeenCalled();
});
it("reuses a fill already on the anonymous draft", () => {
window.localStorage.setItem(
CREATE_FLOW_ANONYMOUS_KEY,
JSON.stringify({
title: "Oak Park Commons",
previewSurfaceColor: "hsl(200, 60%, 80%)",
}),
);
expect(resolvePreviewSurfaceFallback(undefined)).toBe(
"hsl(200, 60%, 80%)",
);
expect(randomPreviewSurfaceColor).not.toHaveBeenCalled();
});
it("picks once and writes it onto the anonymous draft", () => {
expect(resolvePreviewSurfaceFallback(undefined)).toBe(
"hsl(12, 50%, 80%)",
);
expect(randomPreviewSurfaceColor).toHaveBeenCalledTimes(1);
expect(readDraftPreviewSurfaceColor()).toBe("hsl(12, 50%, 80%)");
expect(resolvePreviewSurfaceFallback(undefined)).toBe(
"hsl(12, 50%, 80%)",
);
expect(randomPreviewSurfaceColor).toHaveBeenCalledTimes(1);
});
it("does not replace a fill already stored on the draft", () => {
window.localStorage.setItem(
CREATE_FLOW_ANONYMOUS_KEY,
JSON.stringify({
title: "Oak Park Commons",
previewSurfaceColor: "hsl(200, 60%, 80%)",
}),
);
persistDraftPreviewSurfaceColor("hsl(12, 50%, 80%)");
expect(readDraftPreviewSurfaceColor()).toBe("hsl(200, 60%, 80%)");
});
});