Put the group description under the review-card sections and tint the card from the logo #83
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);
|
||||||
isPreviewSurfaceColor(prev.previewSurfaceColor)
|
const keep =
|
||||||
|
(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%)");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user