Put the group description under the review-card sections and tint the card from the logo #83
No files matched your search
@@ -3,39 +3,60 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
dominantColorFromImageUrl,
|
dominantColorFromImageUrl,
|
||||||
|
isPreviewSurfaceColor,
|
||||||
randomPreviewSurfaceColor,
|
randomPreviewSurfaceColor,
|
||||||
} from "../../../../lib/create/previewSurfaceColor";
|
} 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
|
||||||
* common color; no image uses a random light color. Unset until mount so the
|
* common color; no image uses a random light color stored on the draft so
|
||||||
* server and client share the fallback class.
|
* reload keeps the same fill. Unset until mount so the server and client
|
||||||
|
* share the fallback class.
|
||||||
*
|
*
|
||||||
* @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 persistFallback Writes the fallback onto the draft once.
|
||||||
* @returns A CSS color, or `undefined` before the client picks one.
|
* @returns A CSS color, or `undefined` before the client picks one.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const color = usePreviewSurfaceColor(avatarUrl, state.previewSurfaceColor, persist);
|
||||||
*/
|
*/
|
||||||
export function usePreviewSurfaceColor(
|
export function usePreviewSurfaceColor(
|
||||||
imageUrl: string | null,
|
imageUrl: string | null,
|
||||||
|
storedFallback: string | undefined,
|
||||||
|
persistFallback: (_color: string) => void,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const [color, setColor] = useState<string | undefined>(undefined);
|
const [color, setColor] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
// 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(randomPreviewSurfaceColor());
|
setColor(storedOrRandom(storedFallback, persistFallback));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void dominantColorFromImageUrl(imageUrl).then((next) => {
|
void dominantColorFromImageUrl(imageUrl).then((next) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setColor(next ?? randomPreviewSurfaceColor());
|
setColor(next ?? storedOrRandom(storedFallback, persistFallback));
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [imageUrl]);
|
}, [imageUrl, persistFallback, storedFallback]);
|
||||||
|
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
getAssetPath,
|
getAssetPath,
|
||||||
vectorMarkPath,
|
vectorMarkPath,
|
||||||
} from "../../../../../lib/assetUtils";
|
} from "../../../../../lib/assetUtils";
|
||||||
|
import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor";
|
||||||
|
|
||||||
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
|
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
|
||||||
"coreValues",
|
"coreValues",
|
||||||
@@ -240,7 +241,26 @@ export function FinalReviewScreen({
|
|||||||
state.communityAvatarUrl.trim().length > 0
|
state.communityAvatarUrl.trim().length > 0
|
||||||
? state.communityAvatarUrl.trim()
|
? state.communityAvatarUrl.trim()
|
||||||
: null;
|
: null;
|
||||||
const surfaceColor = usePreviewSurfaceColor(avatarUrl);
|
const storedFallback =
|
||||||
|
typeof state.previewSurfaceColor === "string"
|
||||||
|
? state.previewSurfaceColor
|
||||||
|
: undefined;
|
||||||
|
const persistFallback = useCallback(
|
||||||
|
(color: string) => {
|
||||||
|
replaceState((prev) =>
|
||||||
|
prev.previewSurfaceColor &&
|
||||||
|
isPreviewSurfaceColor(prev.previewSurfaceColor)
|
||||||
|
? prev
|
||||||
|
: { ...prev, previewSurfaceColor: color },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[replaceState],
|
||||||
|
);
|
||||||
|
const surfaceColor = usePreviewSurfaceColor(
|
||||||
|
avatarUrl,
|
||||||
|
storedFallback,
|
||||||
|
persistFallback,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowLockupCardStepShell
|
<CreateFlowLockupCardStepShell
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ export interface CreateFlowState {
|
|||||||
* Set after successful `POST /api/uploads` with purpose `communityAvatar`.
|
* Set after successful `POST /api/uploads` with purpose `communityAvatar`.
|
||||||
*/
|
*/
|
||||||
communityAvatarUrl?: string;
|
communityAvatarUrl?: string;
|
||||||
|
/**
|
||||||
|
* Light `hsl(...)` fill for the review card when no logo is uploaded.
|
||||||
|
* Picked once and kept on the draft so reload does not change it.
|
||||||
|
*/
|
||||||
|
previewSurfaceColor?: string;
|
||||||
/** Selected chip ids from `community-size` (MultiSelect). */
|
/** Selected chip ids from `community-size` (MultiSelect). */
|
||||||
selectedCommunitySizeIds?: string[];
|
selectedCommunitySizeIds?: string[];
|
||||||
/** Selected chip ids from `community-structure` (organization types). */
|
/** Selected chip ids from `community-structure` (organization types). */
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ export function randomPreviewSurfaceColor(
|
|||||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STORED_PREVIEW_SURFACE_COLOR =
|
||||||
|
/^hsl\(\d{1,3}, \d{1,3}%, \d{1,3}%\)$/;
|
||||||
|
|
||||||
|
/** True when `value` is a draft-stored review-card fallback fill. */
|
||||||
|
export function isPreviewSurfaceColor(value: string): boolean {
|
||||||
|
return STORED_PREVIEW_SURFACE_COLOR.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
function imageNeedsCors(url: string): boolean {
|
function imageNeedsCors(url: string): boolean {
|
||||||
if (
|
if (
|
||||||
url.startsWith("/") ||
|
url.startsWith("/") ||
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export const createFlowStateSchema = z
|
|||||||
communityContext: z.string().max(200).optional(),
|
communityContext: z.string().max(200).optional(),
|
||||||
communitySaveEmail: z.string().max(320).optional(),
|
communitySaveEmail: z.string().max(320).optional(),
|
||||||
communityAvatarUrl: z.string().max(512).optional(),
|
communityAvatarUrl: z.string().max(512).optional(),
|
||||||
|
previewSurfaceColor: z
|
||||||
|
.string()
|
||||||
|
.max(32)
|
||||||
|
.regex(/^hsl\(\d{1,3}, \d{1,3}%, \d{1,3}%\)$/)
|
||||||
|
.optional(),
|
||||||
selectedCommunitySizeIds: z.array(z.string()).optional(),
|
selectedCommunitySizeIds: z.array(z.string()).optional(),
|
||||||
selectedOrganizationTypeIds: z.array(z.string()).optional(),
|
selectedOrganizationTypeIds: z.array(z.string()).optional(),
|
||||||
selectedScaleIds: z.array(z.string()).optional(),
|
selectedScaleIds: z.array(z.string()).optional(),
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useLayoutEffect } from "react";
|
import { useEffect, useLayoutEffect } from "react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { 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 { dominantColorFromImageUrl } from "../../lib/create/previewSurfaceColor";
|
import {
|
||||||
|
dominantColorFromImageUrl,
|
||||||
|
randomPreviewSurfaceColor,
|
||||||
|
} from "../../lib/create/previewSurfaceColor";
|
||||||
|
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||||
|
|
||||||
vi.mock("../../lib/create/previewSurfaceColor", async (importOriginal) => {
|
vi.mock("../../lib/create/previewSurfaceColor", async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
@@ -15,6 +19,7 @@ vi.mock("../../lib/create/previewSurfaceColor", async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
dominantColorFromImageUrl: vi.fn(async () => "rgb(12, 80, 40)"),
|
dominantColorFromImageUrl: vi.fn(async () => "rgb(12, 80, 40)"),
|
||||||
|
randomPreviewSurfaceColor: vi.fn(() => "hsl(12, 50%, 80%)"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,6 +35,30 @@ function FinalReviewWithAvatar() {
|
|||||||
return <FinalReviewScreen />;
|
return <FinalReviewScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FinalReviewWithStateProbe({
|
||||||
|
initial,
|
||||||
|
onState,
|
||||||
|
}: {
|
||||||
|
initial: CreateFlowState;
|
||||||
|
onState: (_state: CreateFlowState) => void;
|
||||||
|
}) {
|
||||||
|
const { state, replaceState } = useCreateFlow();
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
replaceState(initial);
|
||||||
|
}, [replaceState, initial]);
|
||||||
|
useEffect(() => {
|
||||||
|
onState(state);
|
||||||
|
}, [onState, state]);
|
||||||
|
if (state.title !== initial.title) return null;
|
||||||
|
if (
|
||||||
|
initial.previewSurfaceColor &&
|
||||||
|
state.previewSurfaceColor !== initial.previewSurfaceColor
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <FinalReviewScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
describe("Final review preview surface", () => {
|
describe("Final review preview surface", () => {
|
||||||
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 />);
|
||||||
@@ -45,4 +74,46 @@ describe("Final review preview surface", () => {
|
|||||||
screen.getByRole("img", { name: "Oak Park Commons" }),
|
screen.getByRole("img", { name: "Oak Park Commons" }),
|
||||||
).toHaveAttribute("src", expect.stringContaining("avatar-1"));
|
).toHaveAttribute("src", expect.stringContaining("avatar-1"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes a fallback fill onto the draft when no logo is uploaded", async () => {
|
||||||
|
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(12, 50%, 80%)");
|
||||||
|
});
|
||||||
|
expect(vi.mocked(randomPreviewSurfaceColor)).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the stored fallback instead of picking a new color", async () => {
|
||||||
|
vi.mocked(randomPreviewSurfaceColor).mockClear();
|
||||||
|
render(
|
||||||
|
<FinalReviewWithStateProbe
|
||||||
|
initial={{
|
||||||
|
title: "Oak Park Commons",
|
||||||
|
communityContext: "Local mutual aid.",
|
||||||
|
previewSurfaceColor: "hsl(200, 60%, 80%)",
|
||||||
|
}}
|
||||||
|
onState={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const card = screen.getByRole("button", { name: /Oak Park Commons/i });
|
||||||
|
expect(card.style.backgroundColor).not.toBe("");
|
||||||
|
});
|
||||||
|
expect(vi.mocked(randomPreviewSurfaceColor)).not.toHaveBeenCalled();
|
||||||
|
const card = screen.getByRole("button", { name: /Oak Park Commons/i });
|
||||||
|
expect(card.style.backgroundColor).not.toBe("rgb(12, 80, 40)");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -148,6 +148,20 @@ describe("createFlowStateSchema", () => {
|
|||||||
expect(r.success).toBe(true);
|
expect(r.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts previewSurfaceColor hsl fills", () => {
|
||||||
|
const r = createFlowStateSchema.safeParse({
|
||||||
|
previewSurfaceColor: "hsl(12, 50%, 80%)",
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects previewSurfaceColor that is not an hsl fill", () => {
|
||||||
|
const r = createFlowStateSchema.safeParse({
|
||||||
|
previewSurfaceColor: "red",
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts templateReviewBackSlug", () => {
|
it("accepts templateReviewBackSlug", () => {
|
||||||
const r = createFlowStateSchema.safeParse({
|
const r = createFlowStateSchema.safeParse({
|
||||||
templateReviewBackSlug: "mutual-aid-mondays",
|
templateReviewBackSlug: "mutual-aid-mondays",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
isPreviewSurfaceColor,
|
||||||
mostCommonOpaqueColor,
|
mostCommonOpaqueColor,
|
||||||
randomPreviewSurfaceColor,
|
randomPreviewSurfaceColor,
|
||||||
readableCardFill,
|
readableCardFill,
|
||||||
@@ -75,3 +76,15 @@ describe("randomPreviewSurfaceColor", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isPreviewSurfaceColor", () => {
|
||||||
|
it("accepts the hsl strings this module writes", () => {
|
||||||
|
expect(isPreviewSurfaceColor("hsl(12, 50%, 80%)")).toBe(true);
|
||||||
|
expect(isPreviewSurfaceColor("hsl(359, 84%, 87%)")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects other CSS colors", () => {
|
||||||
|
expect(isPreviewSurfaceColor("rgb(12, 80, 40)")).toBe(false);
|
||||||
|
expect(isPreviewSurfaceColor("red")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user