Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | 4x 4x 4x 4x 4x 12x 188x 10x 28x 14x 10x 10x 30x 182x 182x 544x 362x 544x 182x | /**
* PhaseRail — the Job Book's footer phase rail (`.phaserail`, mockups §7.6).
*
* ● Studio details ──── ○ First project ──── ○ You're live
*
* Printed at the foot of `.context/mockups/onboarding.html` and of
* `today-first-run.html`: three phases, a 10px ring per phase, a hairline
* between them. The current phase is marked by ink weight and a FILLED dot —
* never by index red, which marks the current *item* in a list as a 3px inset
* bar and nothing else.
*
* WHY THE STATE COMES FROM THE SERVER
* The rail says "you are here" about going live, and "live" now has exactly
* one definition: `apps/api/src/lib/publish-readiness.ts`, served by
* `GET /api/pro/:proId/publish-readiness`. `phasesFromReadiness()` below is
* the only mapping from that response to this rail — a client-side guess about
* what counts as done would be a second definition of the bar, which is the
* thing decision 5 exists to prevent. Where the answer has not arrived yet
* (or the studio row does not exist) the rail falls back to
* `DEFAULT_SETUP_PHASES`: phase one current, nothing claimed done.
*
* ACCESSIBILITY
* An ordered list, because the phases have an order. The current phase carries
* `aria-current="step"`, and every phase prints its state as `sr-only` text —
* dot fill and ink weight are the visual signal, so the state must also be
* readable (WCAG 1.4.1: colour is never the only carrier).
*/
import type { ReactNode } from "react";
import { cn } from "../../lib/utils";
/** Where a phase stands. */
export type PhaseState = "done" | "current" | "upcoming";
export interface Phase {
id: string;
label: string;
state: PhaseState;
}
/** Read out after the phase label; the dot alone cannot say this. */
const STATE_READING: Record<PhaseState, string> = {
done: "done",
current: "in progress",
upcoming: "not started yet",
};
const PHASE_INK: Record<PhaseState, string> = {
done: "text-foreground-muted",
current: "font-semibold text-foreground-default",
upcoming: "text-foreground-subtle",
};
/**
* The dot is 10px (`size-2.5`) with a 2px ring in `currentColor`, so an
* upcoming phase is an empty ring in subtle ink and the two settled states
* fill it: success ink for done, page ink for current.
*/
const PHASE_DOT: Record<PhaseState, string> = {
done: "border-success bg-success",
current: "border-foreground-default bg-foreground-default",
upcoming: "bg-transparent",
};
/** The three phases of getting a studio live, in order. */
export const SETUP_PHASE_LABELS = [
{ id: "studio-details", label: "Studio details" },
{ id: "first-project", label: "First project" },
{ id: "live", label: "You're live" },
] as const;
/**
* The rail before the server has answered: nothing is claimed done, and the
* pro is on the first phase.
*/
export const DEFAULT_SETUP_PHASES: Phase[] = SETUP_PHASE_LABELS.map(
(phase, index) => ({
...phase,
state: index === 0 ? "current" : "upcoming",
}),
);
/** The slice of the readiness response this rail reads. */
export interface PhaseReadiness {
profileComplete: boolean;
requirements: { group: "profile" | "portfolio"; passes: boolean }[];
studioStatus: "draft" | "published" | "archived";
}
/**
* Maps `GET /api/pro/:proId/publish-readiness` onto the three phases.
*
* Studio details → the server's `profileComplete` (its profile group)
* First project → every requirement in its portfolio group passes
* You're live → `studioStatus === "published"`
*
* The first phase that is not done is the current one. No requirement is
* re-interpreted here and no threshold is restated — read `photoMinimum` from
* the response if you need the number.
*/
export function phasesFromReadiness(
readiness: PhaseReadiness | null | undefined,
): Phase[] {
if (!readiness) return DEFAULT_SETUP_PHASES;
const portfolioComplete = readiness.requirements
.filter((requirement) => requirement.group === "portfolio")
.every((requirement) => requirement.passes);
const done = [
readiness.profileComplete,
portfolioComplete,
readiness.studioStatus === "published",
];
const currentIndex = done.indexOf(false);
return SETUP_PHASE_LABELS.map((phase, index) => ({
...phase,
state: done[index]
? "done"
: index === currentIndex
? "current"
: "upcoming",
}));
}
interface PhaseRailProps {
phases: Phase[];
/** Names the list for assistive tech. */
label?: string;
className?: string;
}
export function PhaseRail({
phases,
label = "Setting up your studio",
className,
}: PhaseRailProps) {
const items: ReactNode[] = [];
phases.forEach((phase, index) => {
if (index > 0) {
items.push(
<li
key={`${phase.id}-link`}
aria-hidden="true"
className="h-0 min-w-5 flex-1 border-t border-border-strong"
/>,
);
}
items.push(
<li
key={phase.id}
{...(phase.state === "current" ? { "aria-current": "step" } : {})}
className={cn(
"inline-flex items-center gap-1.5 whitespace-nowrap",
PHASE_INK[phase.state],
)}
>
<span
aria-hidden="true"
className={cn(
"size-2.5 shrink-0 rounded-full border-2 border-current",
PHASE_DOT[phase.state],
)}
/>
{phase.label}
<span className="sr-only"> — {STATE_READING[phase.state]}</span>
</li>,
);
});
return (
<ol
aria-label={label}
className={cn("flex flex-wrap items-center gap-2 text-sm", className)}
>
{items}
</ol>
);
}
|