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 {
|
||||
captureScreenshot,
|
||||
completeOnboarding,
|
||||
signup,
|
||||
} from "./helpers";
|
||||
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
|
||||
// Step 1: Design your Oo
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Design your Oo/i }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible();
|
||||
|
||||
if (testInfo) await captureScreenshot(page, testInfo, "01-design-your-oo");
|
||||
|
||||
// Live preview should show a BotAvatar
|
||||
const preview = page.locator('[data-rakazo-surface="onboarding"]');
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-oo-designer")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-look-preview")).toBeVisible();
|
||||
|
||||
// Shape selection grid should have 8 options
|
||||
const shapeButtons = page.locator('[aria-pressed]');
|
||||
// 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();
|
||||
}
|
||||
await page.getByTestId("onboarding-look-category-work").click();
|
||||
await page.getByRole("tab", { name: /Look 3 of/i }).click();
|
||||
|
||||
// Color selection: click the 3rd color swatch
|
||||
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');
|
||||
const botAvatar = page.locator('[data-testid="onboarding-look-preview"] svg');
|
||||
await expect(botAvatar).toBeVisible();
|
||||
|
||||
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
|
||||
|
||||
// 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
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Tell us about yourself/i }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
||||
|
||||
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
|
||||
const workStyleButtons = page.locator('button[aria-pressed]:visible');
|
||||
if (await workStyleButtons.count() >= 2) {
|
||||
const workStyleButtons = page.locator("button[aria-pressed]:visible");
|
||||
if ((await workStyleButtons.count()) >= 2) {
|
||||
await workStyleButtons.nth(0).click();
|
||||
}
|
||||
|
||||
// Select communication style
|
||||
const commButtons = page.locator('button[aria-pressed]:visible');
|
||||
if (await commButtons.count() >= 2) {
|
||||
const commButtons = page.locator("button[aria-pressed]:visible");
|
||||
if ((await commButtons.count()) >= 2) {
|
||||
await commButtons.nth(0).click();
|
||||
}
|
||||
|
||||
if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled");
|
||||
|
||||
// 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
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Choose your language/i }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||
|
||||
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", {
|
||||
name: /Server integrations/i,
|
||||
});
|
||||
await integrationsHeading.or(chief).or(
|
||||
page.getByText("Opening chat…"),
|
||||
).waitFor({ timeout: 20_000 });
|
||||
await integrationsHeading
|
||||
.or(chief)
|
||||
.or(page.getByText("Opening chat…"))
|
||||
.waitFor({ timeout: 20_000 });
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
test("onboarding: skip language defaults to English", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test("onboarding: skip language defaults to English", async ({ page }, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `lang-skip-${stamp}@rakazo.test`, "password12", "Skipper");
|
||||
|
||||
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
|
||||
|
||||
// Go through emoji step quickly (just click continue)
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Design your Oo/i }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: /Continue/i }).first().click();
|
||||
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: /Continue/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Preferences step — just continue
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Tell us about yourself/i }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: /Continue/i }).first().click();
|
||||
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: /Continue/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Language step — click Skip
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Choose your language/i }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Skip/i }).click();
|
||||
|
||||
// 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 type { IntegrationSetupState, Me } from "@rakazo/contracts";
|
||||
import {
|
||||
BotAvatar,
|
||||
GROK_COLOR_LIST,
|
||||
GrokShapePreview,
|
||||
SHIPPED_SHAPE_KEYS,
|
||||
Button,
|
||||
Input,
|
||||
} from "@rakazo/ui-web";
|
||||
import type { GrokColorDef } from "@rakazo/ui-web";
|
||||
import { Button, Input } from "@rakazo/ui-web";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
|
||||
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
|
||||
import {
|
||||
type OnboardingEmojiConfig,
|
||||
OnboardingOoDesigner,
|
||||
} from "../components/onboarding/OnboardingOoDesigner";
|
||||
import {
|
||||
computeDiscoveryItems,
|
||||
countConfiguredIntegrations,
|
||||
type OnboardingDiscoveryPhase,
|
||||
} from "../lib/onboarding-discovery";
|
||||
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
|
||||
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 { 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_SPAWN_KEY = "onboarding:first";
|
||||
@ -79,49 +72,23 @@ async function ensureFirstBot(avatarColor?: string): Promise<{ id: string }> {
|
||||
return firstBotEnsure;
|
||||
}
|
||||
|
||||
interface EmojiConfig {
|
||||
color: string;
|
||||
shapeIndex: number;
|
||||
accessory: string;
|
||||
pose: string;
|
||||
avatarValue: string;
|
||||
}
|
||||
|
||||
const SHAPE_OPTIONS = SHIPPED_SHAPE_KEYS.map((key) => ({
|
||||
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 {
|
||||
function defaultEmojiConfig(): OnboardingEmojiConfig {
|
||||
const preset = findLookCategory("calm").looks[0];
|
||||
if (!preset) {
|
||||
return {
|
||||
color: "#8B5CF6",
|
||||
shapeIndex: 0,
|
||||
accessory: "none",
|
||||
pose: "idle",
|
||||
avatarValue: "#8B5CF6::shape_0",
|
||||
};
|
||||
}
|
||||
return {
|
||||
color: "#8B5CF6",
|
||||
shapeIndex: 0,
|
||||
color: preset.color,
|
||||
shapeIndex: preset.shapeIndex,
|
||||
accessory: "none",
|
||||
pose: "idle",
|
||||
avatarValue: "#8B5CF6::shape_0",
|
||||
pose: preset.pose,
|
||||
avatarValue: lookPresetToAvatarValue(preset),
|
||||
};
|
||||
}
|
||||
|
||||
@ -186,19 +153,18 @@ export function OnboardingPage() {
|
||||
const [crewReady, setCrewReady] = useState(false);
|
||||
const [helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
||||
|
||||
const [emojiConfig, setEmojiConfig] = useState<EmojiConfig>(defaultEmojiConfig);
|
||||
const [previewColor, setPreviewColor] = useState(emojiConfig.color);
|
||||
const [previewShape, setPreviewShape] = useState(emojiConfig.shapeIndex);
|
||||
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
||||
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
|
||||
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(() => {
|
||||
void Promise.all([
|
||||
rpc.me(),
|
||||
rpc.integrationSetup.get().catch(() => null),
|
||||
])
|
||||
void Promise.all([rpc.me(), rpc.integrationSetup.get().catch(() => null)])
|
||||
.then(([me, integrations]) => {
|
||||
setBootstrapMe(me);
|
||||
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
|
||||
@ -208,38 +174,6 @@ export function OnboardingPage() {
|
||||
.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) {
|
||||
const hobbies = prefs.hobbies.includes(key)
|
||||
? prefs.hobbies.filter((h) => h !== key)
|
||||
@ -269,7 +203,14 @@ export function OnboardingPage() {
|
||||
profilePersisted,
|
||||
crewReady,
|
||||
}),
|
||||
[bootstrapMe, crewReady, discoveryPhase, integrationServers.length, integrationSetup, profilePersisted],
|
||||
[
|
||||
bootstrapMe,
|
||||
crewReady,
|
||||
discoveryPhase,
|
||||
integrationServers.length,
|
||||
integrationSetup,
|
||||
profilePersisted,
|
||||
],
|
||||
);
|
||||
|
||||
async function createFirstBot() {
|
||||
@ -286,22 +227,25 @@ export function OnboardingPage() {
|
||||
if (prefs.hobbies.length > 0) ctxParts.push(`Hobbies: ${prefs.hobbies.join(", ")}`);
|
||||
if (prefs.workStyle) ctxParts.push(`Work style: ${prefs.workStyle}`);
|
||||
if (prefs.communication) ctxParts.push(`Communication: ${prefs.communication}`);
|
||||
const userCtx = ctxParts.length > 0
|
||||
? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}`
|
||||
: "";
|
||||
const userCtx =
|
||||
ctxParts.length > 0 ? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}` : "";
|
||||
await rpc.bots.update({ botId: bot.id, instructions: userCtx }).catch(() => null);
|
||||
|
||||
const me = await rpc.me().catch(() => null);
|
||||
if (me) setBootstrapMe(me);
|
||||
const harvested = await persistUserProfileHarvest(
|
||||
rpc, collectLocalProfileSignals(me ?? undefined),
|
||||
rpc,
|
||||
collectLocalProfileSignals(me ?? undefined),
|
||||
).catch(() => null);
|
||||
if (harvested) setHelpVibe(harvested);
|
||||
setProfilePersisted(true);
|
||||
for (const serverId of integrationServers) {
|
||||
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) {
|
||||
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">
|
||||
<OnboardingDiscoveryShell
|
||||
items={discoveryItems}
|
||||
compact={step === "emoji" || step === "preferences" || step === "language" || step === "integrations"}
|
||||
compact={
|
||||
step === "emoji" ||
|
||||
step === "preferences" ||
|
||||
step === "language" ||
|
||||
step === "integrations"
|
||||
}
|
||||
>
|
||||
{error && step !== "bot" ? (
|
||||
<p className="mb-4 text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{/* Step: Emoji Configuration */}
|
||||
{step === "emoji" ? (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<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>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>
|
||||
<OnboardingOoDesigner
|
||||
config={emojiConfig}
|
||||
onChange={setEmojiConfig}
|
||||
onContinue={() => setStep("preferences")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Step: Preferences */}
|
||||
@ -443,7 +296,10 @@ export function OnboardingPage() {
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</label>
|
||||
<Input
|
||||
@ -456,7 +312,9 @@ export function OnboardingPage() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
value={prefs.timezone}
|
||||
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
|
||||
@ -466,8 +324,12 @@ export function OnboardingPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-foreground"><Trans>Hobbies & interests</Trans></label>
|
||||
<p className="mt-1 text-xs text-muted-foreground"><Trans>Select all that apply</Trans></p>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
<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">
|
||||
{HOBBY_OPTIONS.map((hobby) => (
|
||||
<button
|
||||
@ -488,8 +350,12 @@ export function OnboardingPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-foreground"><Trans>Work style</Trans></label>
|
||||
<p className="mt-1 text-xs text-muted-foreground"><Trans>How do you like to work?</Trans></p>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
<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">
|
||||
{WORK_STYLE_OPTIONS.map((ws) => (
|
||||
<button
|
||||
@ -510,8 +376,12 @@ export function OnboardingPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-foreground"><Trans>Communication style</Trans></label>
|
||||
<p className="mt-1 text-xs text-muted-foreground"><Trans>How should your Oo talk to you?</Trans></p>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
<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">
|
||||
{COMMUNICATION_OPTIONS.map((comm) => (
|
||||
<button
|
||||
@ -532,8 +402,12 @@ export function OnboardingPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}><Trans>Back</Trans></Button>
|
||||
<Button onClick={() => setStep("language")}><Trans>Continue</Trans></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => setStep("language")}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@ -568,8 +442,12 @@ export function OnboardingPage() {
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}><Trans>Back</Trans></Button>
|
||||
<Button onClick={() => setStep(needsIntegrationSetup ? "integrations" : "bot")}><Trans>Skip · English</Trans></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => setStep(needsIntegrationSetup ? "integrations" : "bot")}>
|
||||
<Trans>Skip · English</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@ -595,7 +473,9 @@ export function OnboardingPage() {
|
||||
{step === "bot" && error ? (
|
||||
<div className="text-center">
|
||||
<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>
|
||||
) : null}
|
||||
</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 {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user