evOo-local/apps/web/src/components/onboarding/OnboardingOoDesigner.tsx

179 lines
5.5 KiB
TypeScript

import { Trans, useLingui } from "@lingui/react/macro";
import type { EvooPose } from "@rakazo/ui-web";
import { BotAvatar, Button } from "@rakazo/ui-web";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import {
findLookCategory,
LOOK_CATEGORIES,
type LookCategoryId,
type LookPreset,
lookPresetToAvatarValue,
} from "../../lib/onboarding-look-categories";
import { OoAtelierDialog } from "../OoAtelier";
import { LookPreviewCarousel } from "./LookPreviewCarousel";
export interface OnboardingEmojiConfig {
pose: string;
avatarValue: string;
}
type OnboardingOoDesignerProps = {
config: OnboardingEmojiConfig;
onChange: (config: OnboardingEmojiConfig) => void;
onContinue: () => void;
};
function categoryLabel(id: LookCategoryId): ReactNode {
switch (id) {
case "work":
return <Trans>Work</Trans>;
case "hobby":
return <Trans>Hobby</Trans>;
case "scene":
return <Trans>Scene</Trans>;
case "coder":
return <Trans>Coder</Trans>;
case "calm":
return <Trans>Calm helper</Trans>;
default: {
const never: never = id;
return never;
}
}
}
function configFromPreset(preset: LookPreset): OnboardingEmojiConfig {
return { pose: preset.pose, avatarValue: lookPresetToAvatarValue(preset) };
}
export function OnboardingOoDesigner({ config, onChange, onContinue }: OnboardingOoDesignerProps) {
const { t } = useLingui();
const [categoryId, setCategoryId] = useState<LookCategoryId>("calm");
const category = useMemo(() => findLookCategory(categoryId), [categoryId]);
const [lookIndex, setLookIndex] = useState(0);
const syncCategorySelection = useCallback(
(nextCategoryId: LookCategoryId, index = 0) => {
const nextCategory = findLookCategory(nextCategoryId);
const preset = nextCategory.looks[index] ?? nextCategory.looks[0];
if (!preset) return;
setCategoryId(nextCategoryId);
setLookIndex(index);
onChange(configFromPreset(preset));
},
[onChange],
);
const [atelierOpen, setAtelierOpen] = useState(false);
useEffect(() => {
const match = LOOK_CATEGORIES.find((entry) =>
entry.looks.some((look) => look.avatar === config.avatarValue),
);
if (match) {
const index = match.looks.findIndex((look) => look.avatar === config.avatarValue);
setCategoryId(match.id);
setLookIndex(index >= 0 ? index : 0);
}
}, []);
const handleLookIndexChange = useCallback(
(index: number) => {
const preset = category.looks[index];
if (!preset) return;
setLookIndex(index);
onChange(configFromPreset(preset));
},
[category.looks, onChange],
);
function resetLook() {
syncCategorySelection("calm", 0);
}
return (
<div className="mx-auto max-w-xl" data-testid="onboarding-oo-designer">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Design your Oo</Trans>
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Pick a vibe, swipe through looks, and watch your Oo come alive.</Trans>
</p>
<LookPreviewCarousel
looks={category.looks}
activeIndex={lookIndex}
onIndexChange={handleLookIndexChange}
preview={
<BotAvatar
color={config.avatarValue}
size={168}
identity="onboarding-preview"
pose={config.pose as EvooPose}
interactive
/>
}
/>
<div className="mt-8">
<p className="text-sm font-medium text-foreground">
<Trans>What feels like you?</Trans>
</p>
<div
className="mt-3 flex flex-wrap gap-2 pb-1"
role="tablist"
aria-label={t`Look families`}
>
{LOOK_CATEGORIES.map((entry) => {
const selected = entry.id === categoryId;
return (
<button
key={entry.id}
type="button"
role="tab"
aria-selected={selected}
data-testid={`onboarding-look-category-${entry.id}`}
onClick={() => syncCategorySelection(entry.id, 0)}
className={`shrink-0 rounded-full border px-4 py-2 text-sm font-medium transition-all duration-300 ${
selected
? "border-primary bg-primary text-primary-foreground shadow-sm scale-[1.02]"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted active:scale-95"
}`}
style={{ transitionTimingFunction: "cubic-bezier(0.34, 1.56, 0.64, 1)" }}
>
{categoryLabel(entry.id)}
</button>
);
})}
</div>
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={resetLook}>
<Trans>Reset</Trans>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setAtelierOpen(true)}
data-testid="onboarding-oo-customize"
>
<Trans>Customize</Trans>
</Button>
</div>
<Button onClick={onContinue}>
<Trans>Continue</Trans>
</Button>
</div>
<OoAtelierDialog
open={atelierOpen}
onOpenChange={setAtelierOpen}
value={config.avatarValue}
identity="onboarding-preview"
onChange={(avatarValue) => onChange({ ...config, avatarValue })}
/>
</div>
);
}