feat(web): WOW mobile-first onboarding look picker with swipe previews (#74)
* feat(web): WOW mobile-first onboarding look picker with swipe previews Replace the form-style emoji step with category chips and a swipeable look carousel. Each preset maps to existing color, shape, and pose taxonomies; avatarValue still lands as color::shape on bot create. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style(web): format onboarding look picker files Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
629346fe89
commit
1ed4868dcf
@ -1,9 +1,5 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import {
|
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
||||||
captureScreenshot,
|
|
||||||
completeOnboarding,
|
|
||||||
signup,
|
|
||||||
} from "./helpers";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E test for the new emoji-first onboarding flow:
|
* E2E test for the new emoji-first onboarding flow:
|
||||||
@ -24,43 +20,29 @@ test("emoji-first onboarding: shape + color selection with live preview", async
|
|||||||
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
|
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
|
||||||
|
|
||||||
// Step 1: Design your Oo
|
// Step 1: Design your Oo
|
||||||
await expect(
|
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible();
|
||||||
page.getByRole("heading", { name: /Design your Oo/i }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "01-design-your-oo");
|
if (testInfo) await captureScreenshot(page, testInfo, "01-design-your-oo");
|
||||||
|
|
||||||
// Live preview should show a BotAvatar
|
await expect(page.getByTestId("onboarding-oo-designer")).toBeVisible();
|
||||||
const preview = page.locator('[data-rakazo-surface="onboarding"]');
|
await expect(page.getByTestId("onboarding-look-preview")).toBeVisible();
|
||||||
await expect(preview).toBeVisible();
|
|
||||||
|
|
||||||
// Shape selection grid should have 8 options
|
await page.getByTestId("onboarding-look-category-work").click();
|
||||||
const shapeButtons = page.locator('[aria-pressed]');
|
await page.getByRole("tab", { name: /Look 3 of/i }).click();
|
||||||
// Click the 4th shape (index 3)
|
|
||||||
const shapes = page.locator('[data-testid*="shape"] , button:has(svg)');
|
|
||||||
if (await shapes.count()) {
|
|
||||||
await shapes.nth(3).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Color selection: click the 3rd color swatch
|
const botAvatar = page.locator('[data-testid="onboarding-look-preview"] svg');
|
||||||
const colorSwatches = page.locator("button[aria-label^=Color]");
|
|
||||||
if (await colorSwatches.count()) {
|
|
||||||
await colorSwatches.nth(2).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the preview updated (the BotAvatar should reflect the new color)
|
|
||||||
const botAvatar = page.locator('[data-rakazo-surface="onboarding"] svg');
|
|
||||||
await expect(botAvatar).toBeVisible();
|
await expect(botAvatar).toBeVisible();
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
|
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
|
||||||
|
|
||||||
// Click Continue → goes to preferences step
|
// Click Continue → goes to preferences step
|
||||||
await page.getByRole("button", { name: /Continue/i }).first().click();
|
await page
|
||||||
|
.getByRole("button", { name: /Continue/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Step 2: Tell us about yourself
|
// Step 2: Tell us about yourself
|
||||||
await expect(
|
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
||||||
page.getByRole("heading", { name: /Tell us about yourself/i }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences");
|
if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences");
|
||||||
|
|
||||||
@ -77,26 +59,27 @@ test("emoji-first onboarding: shape + color selection with live preview", async
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Select work style
|
// Select work style
|
||||||
const workStyleButtons = page.locator('button[aria-pressed]:visible');
|
const workStyleButtons = page.locator("button[aria-pressed]:visible");
|
||||||
if (await workStyleButtons.count() >= 2) {
|
if ((await workStyleButtons.count()) >= 2) {
|
||||||
await workStyleButtons.nth(0).click();
|
await workStyleButtons.nth(0).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select communication style
|
// Select communication style
|
||||||
const commButtons = page.locator('button[aria-pressed]:visible');
|
const commButtons = page.locator("button[aria-pressed]:visible");
|
||||||
if (await commButtons.count() >= 2) {
|
if ((await commButtons.count()) >= 2) {
|
||||||
await commButtons.nth(0).click();
|
await commButtons.nth(0).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled");
|
if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled");
|
||||||
|
|
||||||
// Click Continue → goes to language step
|
// Click Continue → goes to language step
|
||||||
await page.getByRole("button", { name: /Continue/i }).first().click();
|
await page
|
||||||
|
.getByRole("button", { name: /Continue/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Step 3: Language selection
|
// Step 3: Language selection
|
||||||
await expect(
|
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
page.getByRole("heading", { name: /Choose your language/i }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
|
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
|
||||||
|
|
||||||
@ -109,9 +92,10 @@ test("emoji-first onboarding: shape + color selection with live preview", async
|
|||||||
const integrationsHeading = page.getByRole("heading", {
|
const integrationsHeading = page.getByRole("heading", {
|
||||||
name: /Server integrations/i,
|
name: /Server integrations/i,
|
||||||
});
|
});
|
||||||
await integrationsHeading.or(chief).or(
|
await integrationsHeading
|
||||||
page.getByText("Opening chat…"),
|
.or(chief)
|
||||||
).waitFor({ timeout: 20_000 });
|
.or(page.getByText("Opening chat…"))
|
||||||
|
.waitFor({ timeout: 20_000 });
|
||||||
|
|
||||||
if (testInfo) await captureScreenshot(page, testInfo, "06-integrations-or-bot");
|
if (testInfo) await captureScreenshot(page, testInfo, "06-integrations-or-bot");
|
||||||
|
|
||||||
@ -126,30 +110,28 @@ test("emoji-first onboarding: shape + color selection with live preview", async
|
|||||||
if (testInfo) await captureScreenshot(page, testInfo, "07-onboarding-complete");
|
if (testInfo) await captureScreenshot(page, testInfo, "07-onboarding-complete");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("onboarding: skip language defaults to English", async ({
|
test("onboarding: skip language defaults to English", async ({ page }, testInfo) => {
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `lang-skip-${stamp}@rakazo.test`, "password12", "Skipper");
|
await signup(page, `lang-skip-${stamp}@rakazo.test`, "password12", "Skipper");
|
||||||
|
|
||||||
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
|
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
|
||||||
|
|
||||||
// Go through emoji step quickly (just click continue)
|
// Go through emoji step quickly (just click continue)
|
||||||
await expect(
|
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible();
|
||||||
page.getByRole("heading", { name: /Design your Oo/i }),
|
await page
|
||||||
).toBeVisible();
|
.getByRole("button", { name: /Continue/i })
|
||||||
await page.getByRole("button", { name: /Continue/i }).first().click();
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Preferences step — just continue
|
// Preferences step — just continue
|
||||||
await expect(
|
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
||||||
page.getByRole("heading", { name: /Tell us about yourself/i }),
|
await page
|
||||||
).toBeVisible();
|
.getByRole("button", { name: /Continue/i })
|
||||||
await page.getByRole("button", { name: /Continue/i }).first().click();
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Language step — click Skip
|
// Language step — click Skip
|
||||||
await expect(
|
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
page.getByRole("heading", { name: /Choose your language/i }),
|
|
||||||
).toBeVisible();
|
|
||||||
await page.getByRole("button", { name: /Skip/i }).click();
|
await page.getByRole("button", { name: /Skip/i }).click();
|
||||||
|
|
||||||
// Should land in bot chat
|
// Should land in bot chat
|
||||||
|
|||||||
66
apps/web/e2e/onboarding-oo-swipe.spec.ts
Normal file
66
apps/web/e2e/onboarding-oo-swipe.spec.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { captureScreenshot, signup } from "./helpers";
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
hasTouch: true,
|
||||||
|
isMobile: true,
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
});
|
||||||
|
|
||||||
|
async function swipeLook(page: Page, start: [number, number], end: [number, number]) {
|
||||||
|
await page.getByTestId("onboarding-look-carousel").evaluate(
|
||||||
|
(root, { start, end }) => {
|
||||||
|
function touch([clientX, clientY]: [number, number]) {
|
||||||
|
return new Touch({ clientX, clientY, identifier: 1, target: root });
|
||||||
|
}
|
||||||
|
const first = touch(start);
|
||||||
|
root.dispatchEvent(
|
||||||
|
new TouchEvent("touchstart", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
changedTouches: [first],
|
||||||
|
touches: [first],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const last = touch(end);
|
||||||
|
root.dispatchEvent(
|
||||||
|
new TouchEvent("touchend", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
changedTouches: [last],
|
||||||
|
touches: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ start, end },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mobile onboarding: category pick and swipe look previews", async ({ page }, testInfo) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `oo-swipe-${stamp}@rakazo.test`, "password12", "Swipe Tester");
|
||||||
|
|
||||||
|
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
|
||||||
|
await expect(page.getByTestId("onboarding-oo-designer")).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("onboarding-look-preview")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("onboarding-look-category-coder").click();
|
||||||
|
await expect(page.getByTestId("onboarding-look-category-coder")).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondDot = page.getByRole("tab", { name: /Look 2 of/i });
|
||||||
|
await swipeLook(page, [280, 420], [120, 420]);
|
||||||
|
await expect(secondDot).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
|
if (testInfo) await captureScreenshot(page, testInfo, "mobile-onboarding-oo-swipe");
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /Continue/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
||||||
|
});
|
||||||
164
apps/web/src/components/onboarding/LookPreviewCarousel.tsx
Normal file
164
apps/web/src/components/onboarding/LookPreviewCarousel.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { Button } from "@rakazo/ui-web";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { type ReactNode, useCallback, useRef, useState } from "react";
|
||||||
|
import type { LookPreset } from "../../lib/onboarding-look-categories";
|
||||||
|
|
||||||
|
const SWIPE_THRESHOLD_PX = 48;
|
||||||
|
const SPRING_EASING = "cubic-bezier(0.34, 1.56, 0.64, 1)";
|
||||||
|
|
||||||
|
type LookPreviewCarouselProps = {
|
||||||
|
looks: LookPreset[];
|
||||||
|
activeIndex: number;
|
||||||
|
onIndexChange: (index: number) => void;
|
||||||
|
preview: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LookPreviewCarousel({
|
||||||
|
looks,
|
||||||
|
activeIndex,
|
||||||
|
onIndexChange,
|
||||||
|
preview,
|
||||||
|
}: LookPreviewCarouselProps) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const touchStartX = useRef<number | null>(null);
|
||||||
|
const [dragOffset, setDragOffset] = useState(0);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const clampIndex = useCallback(
|
||||||
|
(next: number) => Math.max(0, Math.min(looks.length - 1, next)),
|
||||||
|
[looks.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
onIndexChange(clampIndex(activeIndex - 1));
|
||||||
|
}, [activeIndex, clampIndex, onIndexChange]);
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
onIndexChange(clampIndex(activeIndex + 1));
|
||||||
|
}, [activeIndex, clampIndex, onIndexChange]);
|
||||||
|
|
||||||
|
const finishSwipe = useCallback(
|
||||||
|
(deltaX: number) => {
|
||||||
|
if (deltaX <= -SWIPE_THRESHOLD_PX) goNext();
|
||||||
|
else if (deltaX >= SWIPE_THRESHOLD_PX) goPrev();
|
||||||
|
setDragOffset(0);
|
||||||
|
setIsDragging(false);
|
||||||
|
touchStartX.current = null;
|
||||||
|
},
|
||||||
|
[goNext, goPrev],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full" data-testid="onboarding-look-carousel">
|
||||||
|
<div
|
||||||
|
className="relative touch-pan-y"
|
||||||
|
onTouchStart={(event) => {
|
||||||
|
touchStartX.current = event.touches[0]?.clientX ?? null;
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onTouchMove={(event) => {
|
||||||
|
if (touchStartX.current === null) return;
|
||||||
|
const currentX = event.touches[0]?.clientX ?? touchStartX.current;
|
||||||
|
setDragOffset(currentX - touchStartX.current);
|
||||||
|
}}
|
||||||
|
onTouchEnd={() => {
|
||||||
|
if (touchStartX.current === null) return;
|
||||||
|
finishSwipe(dragOffset);
|
||||||
|
}}
|
||||||
|
onTouchCancel={() => finishSwipe(0)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mx-auto flex max-w-sm items-center justify-center"
|
||||||
|
style={{
|
||||||
|
transform: isDragging ? `translateX(${dragOffset * 0.35}px)` : undefined,
|
||||||
|
transition: isDragging ? "none" : `transform 420ms ${SPRING_EASING}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="hidden shrink-0 md:inline-flex"
|
||||||
|
onClick={goPrev}
|
||||||
|
disabled={activeIndex === 0}
|
||||||
|
aria-label={t`Previous look`}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
key={looks[activeIndex]?.id ?? activeIndex}
|
||||||
|
className="flex flex-1 flex-col items-center px-2"
|
||||||
|
data-testid="onboarding-look-preview"
|
||||||
|
style={{
|
||||||
|
animation: isDragging ? undefined : "onboarding-oo-pop 520ms ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="hidden shrink-0 md:inline-flex"
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={activeIndex >= looks.length - 1}
|
||||||
|
aria-label={t`Next look`}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-3 md:hidden">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={goPrev}
|
||||||
|
disabled={activeIndex === 0}
|
||||||
|
aria-label={t`Previous look`}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<Trans>Swipe for more looks</Trans>
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={activeIndex >= looks.length - 1}
|
||||||
|
aria-label={t`Next look`}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mt-3 flex justify-center gap-1.5"
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t`Look variants`}
|
||||||
|
>
|
||||||
|
{looks.map((look, index) => (
|
||||||
|
<button
|
||||||
|
key={look.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={index === activeIndex}
|
||||||
|
aria-label={t`Look ${index + 1} of ${looks.length}`}
|
||||||
|
onClick={() => onIndexChange(index)}
|
||||||
|
className={`h-1.5 rounded-full transition-all duration-300 ${
|
||||||
|
index === activeIndex
|
||||||
|
? "w-6 bg-primary"
|
||||||
|
: "w-1.5 bg-muted-foreground/40 hover:bg-muted-foreground/70"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
apps/web/src/components/onboarding/OnboardingOoDesigner.tsx
Normal file
186
apps/web/src/components/onboarding/OnboardingOoDesigner.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
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 { LookPreviewCarousel } from "./LookPreviewCarousel";
|
||||||
|
|
||||||
|
export interface OnboardingEmojiConfig {
|
||||||
|
color: string;
|
||||||
|
shapeIndex: number;
|
||||||
|
accessory: string;
|
||||||
|
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 presetFromConfig(config: OnboardingEmojiConfig): LookPreset {
|
||||||
|
return {
|
||||||
|
id: "custom",
|
||||||
|
color: config.color,
|
||||||
|
shapeIndex: config.shapeIndex,
|
||||||
|
pose: config.pose as LookPreset["pose"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function configFromPreset(preset: LookPreset): OnboardingEmojiConfig {
|
||||||
|
return {
|
||||||
|
color: preset.color,
|
||||||
|
shapeIndex: preset.shapeIndex,
|
||||||
|
accessory: "none",
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const match = LOOK_CATEGORIES.find((entry) =>
|
||||||
|
entry.looks.some(
|
||||||
|
(look) =>
|
||||||
|
look.color.toLowerCase() === config.color.toLowerCase() &&
|
||||||
|
look.shapeIndex === config.shapeIndex &&
|
||||||
|
look.pose === config.pose,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
const index = match.looks.findIndex(
|
||||||
|
(look) =>
|
||||||
|
look.color.toLowerCase() === config.color.toLowerCase() &&
|
||||||
|
look.shapeIndex === config.shapeIndex &&
|
||||||
|
look.pose === config.pose,
|
||||||
|
);
|
||||||
|
setCategoryId(match.id);
|
||||||
|
setLookIndex(index >= 0 ? index : 0);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeLook = category.looks[lookIndex] ?? presetFromConfig(config);
|
||||||
|
|
||||||
|
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={lookPresetToAvatarValue(activeLook)}
|
||||||
|
size={168}
|
||||||
|
identity="onboarding-preview"
|
||||||
|
pose={activeLook.pose}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
<Trans>What feels like you?</Trans>
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="mt-3 flex gap-2 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
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">
|
||||||
|
<Button variant="ghost" size="sm" onClick={resetLook}>
|
||||||
|
<Trans>Reset</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onContinue}>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/web/src/lib/onboarding-look-categories.test.ts
Normal file
26
apps/web/src/lib/onboarding-look-categories.test.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
LOOK_CATEGORIES,
|
||||||
|
buildAvatarValue,
|
||||||
|
findLookCategory,
|
||||||
|
lookPresetToAvatarValue,
|
||||||
|
} from "./onboarding-look-categories";
|
||||||
|
|
||||||
|
describe("onboarding-look-categories", () => {
|
||||||
|
it("defines five curated families with at least three looks each", () => {
|
||||||
|
expect(LOOK_CATEGORIES).toHaveLength(5);
|
||||||
|
for (const category of LOOK_CATEGORIES) {
|
||||||
|
expect(category.looks.length).toBeGreaterThanOrEqual(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds avatar values in color::shape format", () => {
|
||||||
|
const look = findLookCategory("coder").looks[0]!;
|
||||||
|
expect(lookPresetToAvatarValue(look)).toBe(buildAvatarValue(look.color, look.shapeIndex));
|
||||||
|
expect(buildAvatarValue("#8B5CF6", 2)).toBe("#8B5CF6::shape_2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first category for unknown ids", () => {
|
||||||
|
expect(findLookCategory("work" as const).id).toBe("work");
|
||||||
|
});
|
||||||
|
});
|
||||||
82
apps/web/src/lib/onboarding-look-categories.ts
Normal file
82
apps/web/src/lib/onboarding-look-categories.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import type { EvooPose } from "@rakazo/ui-web";
|
||||||
|
|
||||||
|
export type LookCategoryId = "work" | "hobby" | "scene" | "coder" | "calm";
|
||||||
|
|
||||||
|
export interface LookPreset {
|
||||||
|
id: string;
|
||||||
|
color: string;
|
||||||
|
shapeIndex: number;
|
||||||
|
pose: EvooPose;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LookCategory {
|
||||||
|
id: LookCategoryId;
|
||||||
|
looks: LookPreset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Curated look families built from existing shape, color, and pose taxonomies. */
|
||||||
|
export const LOOK_CATEGORIES: LookCategory[] = [
|
||||||
|
{
|
||||||
|
id: "work",
|
||||||
|
looks: [
|
||||||
|
{ id: "work-violet-hex", color: "#8B5CF6", shapeIndex: 0, pose: "working" },
|
||||||
|
{ id: "work-blue-tablet", color: "#3B82F6", shapeIndex: 3, pose: "analyzing" },
|
||||||
|
{ id: "work-white-squircle", color: "#FFFFFF", shapeIndex: 2, pose: "listening" },
|
||||||
|
{ id: "work-green-wedge", color: "#10B981", shapeIndex: 1, pose: "reading" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hobby",
|
||||||
|
looks: [
|
||||||
|
{ id: "hobby-orange-blob", color: "#F97316", shapeIndex: 5, pose: "delighted" },
|
||||||
|
{ id: "hobby-yellow-pebble", color: "#EAB308", shapeIndex: 4, pose: "curious" },
|
||||||
|
{ id: "hobby-red-teardrop", color: "#EF4444", shapeIndex: 6, pose: "greeting" },
|
||||||
|
{ id: "hobby-cyan-cloud", color: "#06B6D4", shapeIndex: 7, pose: "speaking" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scene",
|
||||||
|
looks: [
|
||||||
|
{ id: "scene-red-wedge", color: "#EF4444", shapeIndex: 1, pose: "speaking" },
|
||||||
|
{ id: "scene-violet-teardrop", color: "#8B5CF6", shapeIndex: 6, pose: "delighted" },
|
||||||
|
{ id: "scene-cyan-hex", color: "#06B6D4", shapeIndex: 0, pose: "collaborating" },
|
||||||
|
{ id: "scene-orange-squircle", color: "#F97316", shapeIndex: 2, pose: "success" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "coder",
|
||||||
|
looks: [
|
||||||
|
{ id: "coder-cyan-hex", color: "#06B6D4", shapeIndex: 0, pose: "working" },
|
||||||
|
{ id: "coder-green-squircle", color: "#10B981", shapeIndex: 2, pose: "analyzing" },
|
||||||
|
{ id: "coder-blue-tablet", color: "#3B82F6", shapeIndex: 3, pose: "thinking" },
|
||||||
|
{ id: "coder-violet-pebble", color: "#8B5CF6", shapeIndex: 4, pose: "collaborating" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "calm",
|
||||||
|
looks: [
|
||||||
|
{ id: "calm-green-cloud", color: "#10B981", shapeIndex: 7, pose: "greeting" },
|
||||||
|
{ id: "calm-violet-blob", color: "#8B5CF6", shapeIndex: 5, pose: "listening" },
|
||||||
|
{ id: "calm-white-pebble", color: "#FFFFFF", shapeIndex: 4, pose: "idle" },
|
||||||
|
{ id: "calm-cyan-squircle", color: "#06B6D4", shapeIndex: 2, pose: "curious" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function findLookCategory(id: LookCategoryId): LookCategory {
|
||||||
|
const category = LOOK_CATEGORIES.find((entry) => entry.id === id);
|
||||||
|
if (!category) {
|
||||||
|
const fallback = LOOK_CATEGORIES[0];
|
||||||
|
if (!fallback) throw new Error("LOOK_CATEGORIES is empty");
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAvatarValue(color: string, shapeIndex: number): string {
|
||||||
|
return `${color}::shape_${shapeIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookPresetToAvatarValue(look: LookPreset): string {
|
||||||
|
return buildAvatarValue(look.color, look.shapeIndex);
|
||||||
|
}
|
||||||
@ -1,31 +1,24 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { IntegrationSetupState, Me } from "@rakazo/contracts";
|
import type { IntegrationSetupState, Me } from "@rakazo/contracts";
|
||||||
import {
|
import { Button, Input } from "@rakazo/ui-web";
|
||||||
BotAvatar,
|
|
||||||
GROK_COLOR_LIST,
|
|
||||||
GrokShapePreview,
|
|
||||||
SHIPPED_SHAPE_KEYS,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
} from "@rakazo/ui-web";
|
|
||||||
import type { GrokColorDef } from "@rakazo/ui-web";
|
|
||||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
|
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
|
||||||
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
|
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
|
||||||
|
import {
|
||||||
|
type OnboardingEmojiConfig,
|
||||||
|
OnboardingOoDesigner,
|
||||||
|
} from "../components/onboarding/OnboardingOoDesigner";
|
||||||
import {
|
import {
|
||||||
computeDiscoveryItems,
|
computeDiscoveryItems,
|
||||||
countConfiguredIntegrations,
|
countConfiguredIntegrations,
|
||||||
type OnboardingDiscoveryPhase,
|
type OnboardingDiscoveryPhase,
|
||||||
} from "../lib/onboarding-discovery";
|
} from "../lib/onboarding-discovery";
|
||||||
|
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
|
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
|
||||||
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
|
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
|
||||||
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
|
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
|
||||||
import {
|
|
||||||
UI_LOCALE_LABELS,
|
|
||||||
persistUiLocale,
|
|
||||||
type UiLocale,
|
|
||||||
} from "../lib/ui-locale";
|
|
||||||
|
|
||||||
const FIRST_BOT_NAME = "Chief";
|
const FIRST_BOT_NAME = "Chief";
|
||||||
const FIRST_BOT_SPAWN_KEY = "onboarding:first";
|
const FIRST_BOT_SPAWN_KEY = "onboarding:first";
|
||||||
@ -79,49 +72,23 @@ async function ensureFirstBot(avatarColor?: string): Promise<{ id: string }> {
|
|||||||
return firstBotEnsure;
|
return firstBotEnsure;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmojiConfig {
|
function defaultEmojiConfig(): OnboardingEmojiConfig {
|
||||||
color: string;
|
const preset = findLookCategory("calm").looks[0];
|
||||||
shapeIndex: number;
|
if (!preset) {
|
||||||
accessory: string;
|
return {
|
||||||
pose: string;
|
color: "#8B5CF6",
|
||||||
avatarValue: string;
|
shapeIndex: 0,
|
||||||
}
|
accessory: "none",
|
||||||
|
pose: "idle",
|
||||||
const SHAPE_OPTIONS = SHIPPED_SHAPE_KEYS.map((key) => ({
|
avatarValue: "#8B5CF6::shape_0",
|
||||||
key,
|
};
|
||||||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
}
|
||||||
}));
|
|
||||||
|
|
||||||
const ACCESSORY_OPTIONS = [
|
|
||||||
{ key: "none", label: <Trans>None</Trans> },
|
|
||||||
{ key: "headset", label: <Trans>Headset</Trans> },
|
|
||||||
{ key: "glasses", label: <Trans>Glasses</Trans> },
|
|
||||||
{ key: "clipboard", label: <Trans>Clipboard</Trans> },
|
|
||||||
{ key: "spark", label: <Trans>Spark</Trans> },
|
|
||||||
{ key: "tool", label: <Trans>Tool</Trans> },
|
|
||||||
{ key: "magnifier", label: <Trans>Magnifier</Trans> },
|
|
||||||
{ key: "thought", label: <Trans>Thought</Trans> },
|
|
||||||
{ key: "zzz", label: <Trans>Zzz</Trans> },
|
|
||||||
];
|
|
||||||
|
|
||||||
const POSE_OPTIONS = [
|
|
||||||
{ key: "idle", label: <Trans>Idle</Trans> },
|
|
||||||
{ key: "greeting", label: <Trans>Greeting</Trans> },
|
|
||||||
{ key: "thinking", label: <Trans>Thinking</Trans> },
|
|
||||||
{ key: "working", label: <Trans>Working</Trans> },
|
|
||||||
{ key: "listening", label: <Trans>Listening</Trans> },
|
|
||||||
{ key: "speaking", label: <Trans>Speaking</Trans> },
|
|
||||||
{ key: "success", label: <Trans>Success</Trans> },
|
|
||||||
{ key: "curious", label: <Trans>Curious</Trans> },
|
|
||||||
];
|
|
||||||
|
|
||||||
function defaultEmojiConfig(): EmojiConfig {
|
|
||||||
return {
|
return {
|
||||||
color: "#8B5CF6",
|
color: preset.color,
|
||||||
shapeIndex: 0,
|
shapeIndex: preset.shapeIndex,
|
||||||
accessory: "none",
|
accessory: "none",
|
||||||
pose: "idle",
|
pose: preset.pose,
|
||||||
avatarValue: "#8B5CF6::shape_0",
|
avatarValue: lookPresetToAvatarValue(preset),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,19 +153,18 @@ export function OnboardingPage() {
|
|||||||
const [crewReady, setCrewReady] = useState(false);
|
const [crewReady, setCrewReady] = useState(false);
|
||||||
const [helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
const [helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
||||||
|
|
||||||
const [emojiConfig, setEmojiConfig] = useState<EmojiConfig>(defaultEmojiConfig);
|
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
||||||
const [previewColor, setPreviewColor] = useState(emojiConfig.color);
|
|
||||||
const [previewShape, setPreviewShape] = useState(emojiConfig.shapeIndex);
|
|
||||||
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
|
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
|
||||||
const [currentLocale, setCurrentLocale] = useState<string>(() => {
|
const [currentLocale, setCurrentLocale] = useState<string>(() => {
|
||||||
try { return localStorage.getItem("rakazo.uiLocale") ?? "en"; } catch { return "en"; }
|
try {
|
||||||
|
return localStorage.getItem("rakazo.uiLocale") ?? "en";
|
||||||
|
} catch {
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([
|
void Promise.all([rpc.me(), rpc.integrationSetup.get().catch(() => null)])
|
||||||
rpc.me(),
|
|
||||||
rpc.integrationSetup.get().catch(() => null),
|
|
||||||
])
|
|
||||||
.then(([me, integrations]) => {
|
.then(([me, integrations]) => {
|
||||||
setBootstrapMe(me);
|
setBootstrapMe(me);
|
||||||
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
|
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
|
||||||
@ -208,38 +174,6 @@ export function OnboardingPage() {
|
|||||||
.catch(() => setStep("emoji"));
|
.catch(() => setStep("emoji"));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateAvatarValue = (config: EmojiConfig) => {
|
|
||||||
const newValue = `${config.color}::shape_${config.shapeIndex}`;
|
|
||||||
setEmojiConfig({ ...config, avatarValue: newValue });
|
|
||||||
};
|
|
||||||
|
|
||||||
function selectColor(color: string) {
|
|
||||||
setPreviewColor(color);
|
|
||||||
const next = { ...emojiConfig, color };
|
|
||||||
updateAvatarValue(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectShape(shapeIndex: number) {
|
|
||||||
setPreviewShape(shapeIndex);
|
|
||||||
const next = { ...emojiConfig, shapeIndex };
|
|
||||||
updateAvatarValue(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectAccessory(key: string) {
|
|
||||||
setEmojiConfig({ ...emojiConfig, accessory: key });
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPose(key: string) {
|
|
||||||
setEmojiConfig({ ...emojiConfig, pose: key });
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetEmoji() {
|
|
||||||
const def = defaultEmojiConfig();
|
|
||||||
setEmojiConfig(def);
|
|
||||||
setPreviewColor(def.color);
|
|
||||||
setPreviewShape(def.shapeIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleHobby(key: string) {
|
function toggleHobby(key: string) {
|
||||||
const hobbies = prefs.hobbies.includes(key)
|
const hobbies = prefs.hobbies.includes(key)
|
||||||
? prefs.hobbies.filter((h) => h !== key)
|
? prefs.hobbies.filter((h) => h !== key)
|
||||||
@ -269,7 +203,14 @@ export function OnboardingPage() {
|
|||||||
profilePersisted,
|
profilePersisted,
|
||||||
crewReady,
|
crewReady,
|
||||||
}),
|
}),
|
||||||
[bootstrapMe, crewReady, discoveryPhase, integrationServers.length, integrationSetup, profilePersisted],
|
[
|
||||||
|
bootstrapMe,
|
||||||
|
crewReady,
|
||||||
|
discoveryPhase,
|
||||||
|
integrationServers.length,
|
||||||
|
integrationSetup,
|
||||||
|
profilePersisted,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function createFirstBot() {
|
async function createFirstBot() {
|
||||||
@ -286,22 +227,25 @@ export function OnboardingPage() {
|
|||||||
if (prefs.hobbies.length > 0) ctxParts.push(`Hobbies: ${prefs.hobbies.join(", ")}`);
|
if (prefs.hobbies.length > 0) ctxParts.push(`Hobbies: ${prefs.hobbies.join(", ")}`);
|
||||||
if (prefs.workStyle) ctxParts.push(`Work style: ${prefs.workStyle}`);
|
if (prefs.workStyle) ctxParts.push(`Work style: ${prefs.workStyle}`);
|
||||||
if (prefs.communication) ctxParts.push(`Communication: ${prefs.communication}`);
|
if (prefs.communication) ctxParts.push(`Communication: ${prefs.communication}`);
|
||||||
const userCtx = ctxParts.length > 0
|
const userCtx =
|
||||||
? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}`
|
ctxParts.length > 0 ? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}` : "";
|
||||||
: "";
|
|
||||||
await rpc.bots.update({ botId: bot.id, instructions: userCtx }).catch(() => null);
|
await rpc.bots.update({ botId: bot.id, instructions: userCtx }).catch(() => null);
|
||||||
|
|
||||||
const me = await rpc.me().catch(() => null);
|
const me = await rpc.me().catch(() => null);
|
||||||
if (me) setBootstrapMe(me);
|
if (me) setBootstrapMe(me);
|
||||||
const harvested = await persistUserProfileHarvest(
|
const harvested = await persistUserProfileHarvest(
|
||||||
rpc, collectLocalProfileSignals(me ?? undefined),
|
rpc,
|
||||||
|
collectLocalProfileSignals(me ?? undefined),
|
||||||
).catch(() => null);
|
).catch(() => null);
|
||||||
if (harvested) setHelpVibe(harvested);
|
if (harvested) setHelpVibe(harvested);
|
||||||
setProfilePersisted(true);
|
setProfilePersisted(true);
|
||||||
for (const serverId of integrationServers) {
|
for (const serverId of integrationServers) {
|
||||||
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
|
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
|
||||||
}
|
}
|
||||||
const started = await rpc.onboarding.start({ botId: bot.id }).then(() => true).catch(() => false);
|
const started = await rpc.onboarding
|
||||||
|
.start({ botId: bot.id })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
if (started) {
|
if (started) {
|
||||||
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
@ -323,113 +267,22 @@ export function OnboardingPage() {
|
|||||||
<div className="min-h-full bg-background px-6 py-12" data-rakazo-surface="onboarding">
|
<div className="min-h-full bg-background px-6 py-12" data-rakazo-surface="onboarding">
|
||||||
<OnboardingDiscoveryShell
|
<OnboardingDiscoveryShell
|
||||||
items={discoveryItems}
|
items={discoveryItems}
|
||||||
compact={step === "emoji" || step === "preferences" || step === "language" || step === "integrations"}
|
compact={
|
||||||
|
step === "emoji" ||
|
||||||
|
step === "preferences" ||
|
||||||
|
step === "language" ||
|
||||||
|
step === "integrations"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{error && step !== "bot" ? (
|
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
|
||||||
<p className="mb-4 text-sm text-destructive">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Step: Emoji Configuration */}
|
{/* Step: Emoji Configuration */}
|
||||||
{step === "emoji" ? (
|
{step === "emoji" ? (
|
||||||
<div className="mx-auto max-w-xl">
|
<OnboardingOoDesigner
|
||||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
config={emojiConfig}
|
||||||
<Trans>Design your Oo</Trans>
|
onChange={setEmojiConfig}
|
||||||
</h2>
|
onContinue={() => setStep("preferences")}
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
/>
|
||||||
<Trans>Give your Oo a look that feels like you — shape, color, accessories.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-8 flex justify-center">
|
|
||||||
<BotAvatar color={previewColor} size={140} identity="onboarding-preview" pose={emojiConfig.pose as any} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Shape */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<label className="block text-sm font-medium text-foreground"><Trans>Shape</Trans></label>
|
|
||||||
<div className="mt-3 grid grid-cols-4 gap-3">
|
|
||||||
{SHAPE_OPTIONS.map((shape, idx) => (
|
|
||||||
<GrokShapePreview
|
|
||||||
key={shape.key}
|
|
||||||
shapeIndex={idx}
|
|
||||||
color={previewColor}
|
|
||||||
selected={previewShape === idx}
|
|
||||||
onClick={() => selectShape(idx)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Color */}
|
|
||||||
<div className="mt-6">
|
|
||||||
<label className="block text-sm font-medium text-foreground"><Trans>Color</Trans></label>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-3">
|
|
||||||
{GROK_COLOR_LIST.map((colorDef: GrokColorDef) => (
|
|
||||||
<button
|
|
||||||
key={colorDef.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectColor(colorDef.hex)}
|
|
||||||
aria-label={`Color ${colorDef.name}`}
|
|
||||||
aria-pressed={previewColor.toLowerCase() === colorDef.hex.toLowerCase()}
|
|
||||||
className={`h-10 w-10 rounded-full border-2 transition-all hover:scale-110 active:scale-95 ${
|
|
||||||
previewColor.toLowerCase() === colorDef.hex.toLowerCase()
|
|
||||||
? "border-primary ring-2 ring-primary ring-offset-2 ring-offset-background scale-105"
|
|
||||||
: "border-border"
|
|
||||||
}`}
|
|
||||||
style={{ backgroundColor: colorDef.hex }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Accessories */}
|
|
||||||
<div className="mt-6">
|
|
||||||
<label className="block text-sm font-medium text-foreground"><Trans>Accessory</Trans></label>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{ACCESSORY_OPTIONS.map((acc) => (
|
|
||||||
<button
|
|
||||||
key={acc.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectAccessory(acc.key)}
|
|
||||||
aria-pressed={emojiConfig.accessory === acc.key}
|
|
||||||
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-all ${
|
|
||||||
emojiConfig.accessory === acc.key
|
|
||||||
? "border-primary bg-primary/10 text-foreground"
|
|
||||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{acc.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pose */}
|
|
||||||
<div className="mt-6">
|
|
||||||
<label className="block text-sm font-medium text-foreground"><Trans>Pose</Trans></label>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{POSE_OPTIONS.map((pose) => (
|
|
||||||
<button
|
|
||||||
key={pose.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectPose(pose.key)}
|
|
||||||
aria-pressed={emojiConfig.pose === pose.key}
|
|
||||||
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-all ${
|
|
||||||
emojiConfig.pose === pose.key
|
|
||||||
? "border-primary bg-primary/10 text-foreground"
|
|
||||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pose.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 flex items-center justify-between">
|
|
||||||
<Button variant="ghost" size="sm" onClick={resetEmoji}><Trans>Reset</Trans></Button>
|
|
||||||
<Button onClick={() => setStep("preferences")}><Trans>Continue</Trans></Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Step: Preferences */}
|
{/* Step: Preferences */}
|
||||||
@ -443,7 +296,10 @@ export function OnboardingPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<label htmlFor={`${fieldId}-name`} className="block text-sm font-medium text-foreground">
|
<label
|
||||||
|
htmlFor={`${fieldId}-name`}
|
||||||
|
className="block text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
<Trans>Name</Trans>
|
<Trans>Name</Trans>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@ -456,7 +312,9 @@ export function OnboardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<label className="block text-sm font-medium text-foreground"><Trans>Timezone</Trans></label>
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
<Trans>Timezone</Trans>
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={prefs.timezone}
|
value={prefs.timezone}
|
||||||
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
|
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
|
||||||
@ -466,8 +324,12 @@ export function OnboardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<label className="block text-sm font-medium text-foreground"><Trans>Hobbies & interests</Trans></label>
|
<label className="block text-sm font-medium text-foreground">
|
||||||
<p className="mt-1 text-xs text-muted-foreground"><Trans>Select all that apply</Trans></p>
|
<Trans>Hobbies & interests</Trans>
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
<Trans>Select all that apply</Trans>
|
||||||
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{HOBBY_OPTIONS.map((hobby) => (
|
{HOBBY_OPTIONS.map((hobby) => (
|
||||||
<button
|
<button
|
||||||
@ -488,8 +350,12 @@ export function OnboardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<label className="block text-sm font-medium text-foreground"><Trans>Work style</Trans></label>
|
<label className="block text-sm font-medium text-foreground">
|
||||||
<p className="mt-1 text-xs text-muted-foreground"><Trans>How do you like to work?</Trans></p>
|
<Trans>Work style</Trans>
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
<Trans>How do you like to work?</Trans>
|
||||||
|
</p>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
{WORK_STYLE_OPTIONS.map((ws) => (
|
{WORK_STYLE_OPTIONS.map((ws) => (
|
||||||
<button
|
<button
|
||||||
@ -510,8 +376,12 @@ export function OnboardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<label className="block text-sm font-medium text-foreground"><Trans>Communication style</Trans></label>
|
<label className="block text-sm font-medium text-foreground">
|
||||||
<p className="mt-1 text-xs text-muted-foreground"><Trans>How should your Oo talk to you?</Trans></p>
|
<Trans>Communication style</Trans>
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
<Trans>How should your Oo talk to you?</Trans>
|
||||||
|
</p>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
{COMMUNICATION_OPTIONS.map((comm) => (
|
{COMMUNICATION_OPTIONS.map((comm) => (
|
||||||
<button
|
<button
|
||||||
@ -532,8 +402,12 @@ export function OnboardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 flex items-center justify-between">
|
<div className="mt-8 flex items-center justify-between">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}><Trans>Back</Trans></Button>
|
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
|
||||||
<Button onClick={() => setStep("language")}><Trans>Continue</Trans></Button>
|
<Trans>Back</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setStep("language")}>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -568,8 +442,12 @@ export function OnboardingPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 flex items-center justify-between">
|
<div className="mt-8 flex items-center justify-between">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}><Trans>Back</Trans></Button>
|
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}>
|
||||||
<Button onClick={() => setStep(needsIntegrationSetup ? "integrations" : "bot")}><Trans>Skip · English</Trans></Button>
|
<Trans>Back</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setStep(needsIntegrationSetup ? "integrations" : "bot")}>
|
||||||
|
<Trans>Skip · English</Trans>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -595,7 +473,9 @@ export function OnboardingPage() {
|
|||||||
{step === "bot" && error ? (
|
{step === "bot" && error ? (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
<Button className="mt-4" onClick={() => void createFirstBot()}><Trans>Try again</Trans></Button>
|
<Button className="mt-4" onClick={() => void createFirstBot()}>
|
||||||
|
<Trans>Try again</Trans>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</OnboardingDiscoveryShell>
|
</OnboardingDiscoveryShell>
|
||||||
|
|||||||
@ -389,6 +389,21 @@ body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes onboarding-oo-pop {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.92);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
transform: scale(1.04);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes voiceGlassRing {
|
@keyframes voiceGlassRing {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user