diff --git a/apps/api/package.json b/apps/api/package.json index 049f035..31fe087 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -32,6 +32,7 @@ "luxon": "^3.7.2", "multer": "^2.0.2", "nodemailer": "^7.0.4", + "sharp": "^0.34.5", "stream-chat": "^9.8.0", "stripe": "^20.0.0", "ts-node": "^10.9.2" diff --git a/apps/api/src/helpers/orgIcons.test.ts b/apps/api/src/helpers/orgIcons.test.ts new file mode 100644 index 0000000..6493d46 --- /dev/null +++ b/apps/api/src/helpers/orgIcons.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resizeOrgIcon, buildOrgIconKey, ICON_SIZES, ORG_ICONS_BUCKET } from "./orgIcons.js"; + +describe("buildOrgIconKey", () => { + it("builds the correct R2 key for a given org and size", () => { + expect(buildOrgIconKey(42, 192)).toBe("org-icons/42/icon-192.png"); + }); + + it("builds the correct key for maskable variant", () => { + expect(buildOrgIconKey(42, 512, true)).toBe("org-icons/42/icon-512-maskable.png"); + }); +}); + +describe("ICON_SIZES", () => { + it("includes all required PWA icon sizes", () => { + const sizes = ICON_SIZES.map((s) => s.size); + expect(sizes).toContain(16); + expect(sizes).toContain(32); + expect(sizes).toContain(180); + expect(sizes).toContain(192); + expect(sizes).toContain(512); + }); + + it("includes a maskable 512 variant", () => { + const maskable = ICON_SIZES.find((s) => s.maskable); + expect(maskable).toBeDefined(); + expect(maskable!.size).toBe(512); + }); +}); + +describe("resizeOrgIcon", () => { + it("returns buffers for all required sizes from a valid PNG input", async () => { + const sharp = (await import("sharp")).default; + const input = await sharp({ + create: { width: 512, height: 512, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } }, + }) + .png() + .toBuffer(); + + const results = await resizeOrgIcon(input); + + expect(results).toHaveLength(ICON_SIZES.length); + for (const result of results) { + expect(result.buffer).toBeInstanceOf(Buffer); + expect(result.buffer.length).toBeGreaterThan(0); + } + }); + + it("rejects images smaller than 512x512", async () => { + const sharp = (await import("sharp")).default; + const tooSmall = await sharp({ + create: { width: 256, height: 256, channels: 4, background: { r: 0, g: 0, b: 255, alpha: 1 } }, + }) + .png() + .toBuffer(); + + await expect(resizeOrgIcon(tooSmall)).rejects.toThrow("minimum 512x512"); + }); +}); diff --git a/apps/api/src/helpers/orgIcons.ts b/apps/api/src/helpers/orgIcons.ts new file mode 100644 index 0000000..df031b3 --- /dev/null +++ b/apps/api/src/helpers/orgIcons.ts @@ -0,0 +1,148 @@ +import { + DeleteObjectsCommand, + GetObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import sharp from "sharp"; + +export const ORG_ICONS_BUCKET = "web-assets"; + +export const ICON_SIZES = [ + { size: 16, maskable: false }, + { size: 32, maskable: false }, + { size: 180, maskable: false }, + { size: 192, maskable: false }, + { size: 512, maskable: false }, + { size: 512, maskable: true }, +] as const; + +export function buildOrgIconKey(orgId: number, size: number, maskable = false): string { + const suffix = maskable ? `-${size}-maskable` : `-${size}`; + return `org-icons/${orgId}/icon${suffix}.png`; +} + +export function buildOrgIconBasePath(orgId: number): string { + return `org-icons/${orgId}/`; +} + +export interface ResizedIcon { + size: number; + maskable: boolean; + buffer: Buffer; + key: string; +} + +export async function resizeOrgIcon(inputBuffer: Buffer): Promise { + const metadata = await sharp(inputBuffer).metadata(); + if (!metadata.width || !metadata.height || metadata.width < 512 || metadata.height < 512) { + throw new Error("Image must be minimum 512x512 pixels"); + } + + const results: ResizedIcon[] = []; + + for (const { size, maskable } of ICON_SIZES) { + // For maskable icons, add 10% padding (safe zone) with white background + if (maskable) { + const innerSize = Math.round(size * 0.8); + const resized = await sharp(inputBuffer) + .resize(innerSize, innerSize, { fit: "cover" }) + .png() + .toBuffer(); + + const padded = await sharp({ + create: { + width: size, + height: size, + channels: 4, + background: { r: 255, g: 255, b: 255, alpha: 1 }, + }, + }) + .composite([{ input: resized, gravity: "center" }]) + .png() + .toBuffer(); + + results.push({ + size, + maskable, + buffer: padded, + key: buildOrgIconKey(0, size, maskable), + }); + continue; + } + + const buffer = await sharp(inputBuffer).resize(size, size, { fit: "cover" }).png().toBuffer(); + results.push({ + size, + maskable, + buffer, + key: buildOrgIconKey(0, size, maskable), + }); + } + + return results; +} + +export async function uploadOrgIcons( + s3Client: S3Client, + orgId: number, + inputBuffer: Buffer +): Promise { + const resized = await resizeOrgIcon(inputBuffer); + + for (const icon of resized) { + const key = buildOrgIconKey(orgId, icon.size, icon.maskable); + await s3Client.send( + new PutObjectCommand({ + Bucket: ORG_ICONS_BUCKET, + Key: key, + Body: icon.buffer, + ContentType: "image/png", + }) + ); + } + + return buildOrgIconBasePath(orgId); +} + +export async function deleteOrgIcons(s3Client: S3Client, orgId: number): Promise { + const prefix = buildOrgIconBasePath(orgId); + const listed = await s3Client.send( + new ListObjectsV2Command({ + Bucket: ORG_ICONS_BUCKET, + Prefix: prefix, + }) + ); + + if (!listed.Contents || listed.Contents.length === 0) return; + + await s3Client.send( + new DeleteObjectsCommand({ + Bucket: ORG_ICONS_BUCKET, + Delete: { Objects: listed.Contents.map(({ Key }) => ({ Key })) }, + }) + ); +} + +export async function getOrgIcon( + s3Client: S3Client, + orgId: number, + size: number, + maskable: boolean +): Promise<{ buffer: Buffer; contentType: string } | null> { + const key = buildOrgIconKey(orgId, size, maskable); + try { + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: ORG_ICONS_BUCKET, + Key: key, + }) + ); + const body = await response.Body?.transformToByteArray(); + if (!body) return null; + return { buffer: Buffer.from(body), contentType: "image/png" }; + } catch { + return null; + } +} diff --git a/apps/api/src/routers/public.ts b/apps/api/src/routers/public.ts index 15af76d..cf71065 100644 --- a/apps/api/src/routers/public.ts +++ b/apps/api/src/routers/public.ts @@ -1,6 +1,8 @@ +import type { S3Client } from "@aws-sdk/client-s3"; import type { Database, Tables } from "@xtablo/shared-types"; import { Hono } from "hono"; import { createFactory } from "hono/factory"; +import { getOrgIcon } from "../helpers/orgIcons.js"; import { type EventTypeConfig, type Exception, @@ -125,10 +127,52 @@ const getPublicSlots = factory.createHandlers(async (c) => { }); }); +const getOrgIconHandler = factory.createHandlers(async (c) => { + const orgIdStr = c.req.param("orgId"); + const filename = c.req.param("filename"); + const orgId = Number.parseInt(orgIdStr, 10); + + if (Number.isNaN(orgId) || !filename) { + return c.json({ error: "Invalid request" }, 400); + } + + // Parse filename: "icon-192.png" or "icon-512-maskable.png" + const match = filename.match(/^icon-(\d+)(-maskable)?\.png$/); + if (!match) { + return c.json({ error: "Invalid icon filename" }, 400); + } + + const size = Number.parseInt(match[1], 10); + const maskable = !!match[2]; + + const s3Client = c.get("s3_client") as S3Client; + const result = await getOrgIcon(s3Client, orgId, size, maskable); + + if (!result) { + // Fallback: redirect to default PWA icon + const defaultPath = maskable + ? "/pwa-icons/pwa-512x512-maskable.png" + : size <= 32 + ? `/pwa-icons/favicon-${size}x${size}.png` + : size === 180 + ? "/pwa-icons/apple-touch-icon-180x180.png" + : `/pwa-icons/pwa-${size}x${size}.png`; + return c.redirect(defaultPath, 302); + } + + return new Response(new Uint8Array(result.buffer), { + headers: { + "Content-Type": result.contentType, + "Cache-Control": "public, max-age=86400", + }, + }); +}); + export const getPublicRouter = () => { const publicRouter = new Hono(); publicRouter.get("/slots/:shortUserId/:standardName", ...getPublicSlots); + publicRouter.get("/org-icons/:orgId/:filename", ...getOrgIconHandler); return publicRouter; }; diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index b4ce427..7616c79 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -4,6 +4,7 @@ import { Hono } from "hono"; import { createFactory } from "hono/factory"; import { getOrganizationBillingState } from "../helpers/billing.js"; import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js"; +import { deleteOrgIcons, uploadOrgIcons } from "../helpers/orgIcons.js"; import type { AuthEnv } from "../types/app.types.js"; const factory = createFactory(); @@ -295,7 +296,7 @@ const getOrganization = factory.createHandlers(async (c) => { const { data: organization, error: organizationError } = await supabase .from("organizations") - .select("id, name") + .select("id, name, logo_url") .eq("id", organizationId) .single(); @@ -419,6 +420,7 @@ const getOrganization = factory.createHandlers(async (c) => { organization: { id: organization.id, name: organization.name, + logo_url: organization.logo_url ?? null, plan, member_count: members?.length || 0, tablo_count: tabloCount || 0, @@ -439,13 +441,8 @@ const getOrganization = factory.createHandlers(async (c) => { const updateOrganization = factory.createHandlers(async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); + const s3Client = c.get("s3_client"); const body = await c.req.json(); - const rawName = typeof body?.name === "string" ? body.name : ""; - const name = rawName.trim(); - - if (name.length < 2 || name.length > 100) { - return c.json({ error: "Organization name must be between 2 and 100 characters" }, 400); - } const { data: profile, error: profileError } = await supabase .from("profiles") @@ -461,13 +458,54 @@ const updateOrganization = factory.createHandlers(async (c) => { return c.json({ error: "Temporary users cannot update organization settings" }, 403); } - const { error: updateError } = await supabase - .from("organizations") - .update({ name }) - .eq("id", profile.organization_id); + const organizationId = profile.organization_id; + const updateData: Record = {}; - if (updateError) { - return c.json({ error: updateError.message }, 500); + // Handle name update + if (body?.name !== undefined) { + const rawName = typeof body.name === "string" ? body.name : ""; + const name = rawName.trim(); + if (name.length < 2 || name.length > 100) { + return c.json({ error: "Organization name must be between 2 and 100 characters" }, 400); + } + updateData.name = name; + } + + // Handle logo upload + if (body?.logo !== undefined) { + if (body.logo === null) { + // Remove logo + await deleteOrgIcons(s3Client, organizationId); + updateData.logo_url = null; + } else if (body.logo?.content && body.logo?.contentType) { + const { content, contentType } = body.logo; + + // Validate content type + const allowedTypes = ["image/png", "image/jpeg", "image/webp"]; + if (!allowedTypes.includes(contentType)) { + return c.json({ error: "Logo must be PNG, JPEG, or WebP" }, 400); + } + + const imageBuffer = Buffer.from(content, "base64"); + try { + const basePath = await uploadOrgIcons(s3Client, organizationId, imageBuffer); + updateData.logo_url = basePath; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to process logo"; + return c.json({ error: message }, 400); + } + } + } + + if (Object.keys(updateData).length > 0) { + const { error: updateError } = await supabase + .from("organizations") + .update(updateData) + .eq("id", organizationId); + + if (updateError) { + return c.json({ error: updateError.message }, 500); + } } return c.json({ message: "Organization updated successfully" }); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 0b31346..e2c7fcf 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ globalSetup: ["./src/__tests__/globalSetup.ts"], testTimeout: 30000, hookTimeout: 60000, - include: ["src/__tests__/**/*.test.ts"], + include: ["src/__tests__/**/*.test.ts", "src/**/*.test.ts"], exclude: ["node_modules", "dist"], reporters: ["verbose"], pool: "forks", diff --git a/apps/api/vitest.unit.config.ts b/apps/api/vitest.unit.config.ts new file mode 100644 index 0000000..41fd3aa --- /dev/null +++ b/apps/api/vitest.unit.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + testTimeout: 30000, + include: ["src/helpers/**/*.test.ts"], + exclude: ["node_modules", "dist"], + reporters: ["verbose"], + pool: "forks", + }, + resolve: { + alias: { + "@": "/src", + }, + }, +}); diff --git a/apps/main/index.html b/apps/main/index.html index 598566b..84577b1 100644 --- a/apps/main/index.html +++ b/apps/main/index.html @@ -2,8 +2,14 @@ - - + + + + + + + + XTablo diff --git a/apps/main/package.json b/apps/main/package.json index 19ac56c..3775a5c 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -39,6 +39,7 @@ "@types/node": "^22.13.10", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", + "@types/sharp": "^0.32.0", "@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/parser": "^7.0.2", "@vitejs/plugin-react": "^4.3.4", @@ -56,6 +57,7 @@ "react-aria-components": "^1.7.0", "react-dom": "19.0.0", "rollup-plugin-visualizer": "^5.14.0", + "sharp": "^0.34.5", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.1.15", "tailwindcss-animate": "^1.0.7", @@ -63,8 +65,10 @@ "typescript": "^5.7.0", "typescript-eslint": "^8.26.1", "vite": "^6.2.2", + "vite-plugin-pwa": "^1.2.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4", + "workbox-window": "^7.4.0", "wrangler": "^4.24.3" }, "dependencies": { diff --git a/apps/main/public/pwa-icons/default-apple-touch-icon-180x180.png b/apps/main/public/pwa-icons/default-apple-touch-icon-180x180.png new file mode 100644 index 0000000..78da98f Binary files /dev/null and b/apps/main/public/pwa-icons/default-apple-touch-icon-180x180.png differ diff --git a/apps/main/public/pwa-icons/default-favicon-16x16.png b/apps/main/public/pwa-icons/default-favicon-16x16.png new file mode 100644 index 0000000..2f3428d Binary files /dev/null and b/apps/main/public/pwa-icons/default-favicon-16x16.png differ diff --git a/apps/main/public/pwa-icons/default-favicon-32x32.png b/apps/main/public/pwa-icons/default-favicon-32x32.png new file mode 100644 index 0000000..f6d7041 Binary files /dev/null and b/apps/main/public/pwa-icons/default-favicon-32x32.png differ diff --git a/apps/main/public/pwa-icons/pwa-192x192.png b/apps/main/public/pwa-icons/pwa-192x192.png new file mode 100644 index 0000000..0a42ff5 Binary files /dev/null and b/apps/main/public/pwa-icons/pwa-192x192.png differ diff --git a/apps/main/public/pwa-icons/pwa-512x512-maskable.png b/apps/main/public/pwa-icons/pwa-512x512-maskable.png new file mode 100644 index 0000000..72b0e39 Binary files /dev/null and b/apps/main/public/pwa-icons/pwa-512x512-maskable.png differ diff --git a/apps/main/public/pwa-icons/pwa-512x512.png b/apps/main/public/pwa-icons/pwa-512x512.png new file mode 100644 index 0000000..b618acf Binary files /dev/null and b/apps/main/public/pwa-icons/pwa-512x512.png differ diff --git a/apps/main/scripts/generate-pwa-icons.ts b/apps/main/scripts/generate-pwa-icons.ts new file mode 100644 index 0000000..ee8a107 --- /dev/null +++ b/apps/main/scripts/generate-pwa-icons.ts @@ -0,0 +1,74 @@ +import sharp from "sharp"; +import path from "node:path"; +import fs from "node:fs"; + +const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public"); +const OUTPUT_DIR = path.join(PUBLIC_DIR, "pwa-icons"); + +const ICON_SIZES = [ + { size: 16, name: "favicon-16x16.png" }, + { size: 32, name: "favicon-32x32.png" }, + { size: 180, name: "apple-touch-icon-180x180.png" }, + { size: 192, name: "pwa-192x192.png" }, + { size: 512, name: "pwa-512x512.png" }, + { size: 512, name: "pwa-512x512-maskable.png", maskable: true }, +]; + +// Maskable icons need 10% safe zone padding (per spec). +// We add a colored background and shrink the source to 80% to stay within the safe zone. +const MASKABLE_PADDING_RATIO = 0.1; +const MASKABLE_BG_COLOR = "#ffffff"; + +async function generateIcons(sourceFile: string) { + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + for (const icon of ICON_SIZES) { + const outputPath = path.join(OUTPUT_DIR, icon.name); + + if (icon.maskable) { + // Create maskable icon with padding and background + const innerSize = Math.round(icon.size * (1 - MASKABLE_PADDING_RATIO * 2)); + const offset = Math.round(icon.size * MASKABLE_PADDING_RATIO); + + const resizedIcon = await sharp(sourceFile) + .resize(innerSize, innerSize, { fit: "contain" }) + .toBuffer(); + + await sharp({ + create: { + width: icon.size, + height: icon.size, + channels: 4, + background: MASKABLE_BG_COLOR, + }, + }) + .composite([{ input: resizedIcon, left: offset, top: offset }]) + .png() + .toFile(outputPath); + } else { + await sharp(sourceFile) + .resize(icon.size, icon.size, { fit: "contain" }) + .png() + .toFile(outputPath); + } + + console.log(`Generated: ${icon.name} (${icon.size}x${icon.size})`); + } +} + +// Determine source: pass "staging" as first arg for staging icons +const variant = process.argv[2]; +const sourceFile = + variant === "staging" + ? path.join(PUBLIC_DIR, "staging_icon.jpg") + : path.join(PUBLIC_DIR, "icon.jpg"); + +if (!fs.existsSync(sourceFile)) { + console.error(`Source icon not found: ${sourceFile}`); + process.exit(1); +} + +console.log(`Generating PWA icons from: ${sourceFile}`); +generateIcons(sourceFile).then(() => console.log("Done!")); diff --git a/apps/main/src/App.tsx b/apps/main/src/App.tsx index a41987e..f92e874 100644 --- a/apps/main/src/App.tsx +++ b/apps/main/src/App.tsx @@ -3,6 +3,7 @@ import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext"; import { Toaster } from "@xtablo/ui/components/sonner"; import { BrowserRouter as Router, useRoutes } from "react-router-dom"; import { CookieBanner } from "./components/CookieBanner"; +import { InstallBanner } from "./components/InstallBanner"; import { PendingSignupCheckout } from "./components/PendingSignupCheckout"; import { PlanAnnouncement } from "./components/PlanAnnouncement"; import { TrialUpsellModal } from "./components/TrialUpsellModal"; @@ -31,6 +32,7 @@ const Routes = () => { return ( + diff --git a/apps/main/src/components/ActionCard.tsx b/apps/main/src/components/ActionCard.tsx index c7de041..d78fdbc 100644 --- a/apps/main/src/components/ActionCard.tsx +++ b/apps/main/src/components/ActionCard.tsx @@ -32,7 +32,7 @@ export function ActionCard({ onClick={disabled ? undefined : onClick} disabled={disabled} className={cn( - "h-fit p-3 rounded-2xl text-left transition-all", + "h-fit p-3 rounded-2xl text-left transition-all min-h-[56px]", disabled ? "bg-white dark:bg-gray-800 border border-[#EAECF0] dark:border-gray-700 opacity-50 cursor-not-allowed" : isSelected diff --git a/apps/main/src/components/CreateTabloModal.tsx b/apps/main/src/components/CreateTabloModal.tsx index 834e60b..29074b3 100644 --- a/apps/main/src/components/CreateTabloModal.tsx +++ b/apps/main/src/components/CreateTabloModal.tsx @@ -49,9 +49,9 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) = }; return ( -
+
-
+

{t("modals:createTablo.title")}

@@ -81,17 +81,17 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) =
{/* Modal Actions */} -
+
- {/* Title */} -

- {task.title} -

- - {/* Tablo */} -
- {task.tablos && ( - <> -
- - {task.tablos.name.charAt(0).toUpperCase()} + {/* Title + Tablo (stacked on mobile) */} +
+

+ {task.title} +

+
+ {task.tablos && ( + <> +
+ + {task.tablos.name.charAt(0).toUpperCase()} + +
+ + {task.tablos.name} -
- - {task.tablos.name} + + )} + {formattedDate && ( + + {formattedDate} - - )} + )} +
- {/* Date */} - - {formattedDate} - - {/* Status badge */} @@ -151,24 +151,22 @@ export function DashboardTaskList() { return ( <>
-
-

+
+

{t("dashboard.taskList.title")}

-
-
- {myTasks.map((task) => ( - - ))} -
+
+ {myTasks.map((task) => ( + + ))}
diff --git a/apps/main/src/components/DeleteTabloModal.tsx b/apps/main/src/components/DeleteTabloModal.tsx index dd45816..08a9f6b 100644 --- a/apps/main/src/components/DeleteTabloModal.tsx +++ b/apps/main/src/components/DeleteTabloModal.tsx @@ -24,9 +24,9 @@ export const DeleteTabloModal = ({ }; return ( -
+
-
+
{/* Header */}
@@ -70,10 +70,10 @@ export const DeleteTabloModal = ({
{/* Actions */} -
+
+ + )} + +
+ ); +} diff --git a/apps/main/src/components/Layout.test.tsx b/apps/main/src/components/Layout.test.tsx index e4449f8..7dcf293 100644 --- a/apps/main/src/components/Layout.test.tsx +++ b/apps/main/src/components/Layout.test.tsx @@ -78,6 +78,6 @@ describe("Layout", () => { const navParent = navigation.parentElement; // Should have transition and mobile/desktop behavior - expect(navParent).toHaveClass("fixed", "md:relative", "transition-all"); + expect(navParent).toHaveClass("fixed", "md:relative", "transition-transform"); }); }); diff --git a/apps/main/src/components/Layout.tsx b/apps/main/src/components/Layout.tsx index 3c4f7f8..a844ede 100644 --- a/apps/main/src/components/Layout.tsx +++ b/apps/main/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { Button } from "@xtablo/ui/components/button"; -import { MenuIcon } from "lucide-react"; -import { useEffect, useState } from "react"; -import { Outlet } from "react-router-dom"; +import { MenuIcon, XIcon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { Outlet, useLocation } from "react-router-dom"; import { twMerge } from "tailwind-merge"; import { SideNavigation } from "./NavigationBar"; import { OnboardingModal } from "./OnboardingModal"; @@ -12,6 +12,7 @@ const ONBOARDING_STORAGE_KEY = "xtablo-onboarding-completed"; export function Layout() { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false); + const location = useLocation(); useEffect(() => { // Check if user has completed onboarding @@ -21,30 +22,64 @@ export function Layout() { } }, []); + // Close mobile menu on route change + useEffect(() => { + setIsMobileMenuOpen(false); + }, [location.pathname]); + const handleOnboardingComplete = () => { localStorage.setItem(ONBOARDING_STORAGE_KEY, "true"); setShowOnboarding(false); }; + const closeMobileMenu = useCallback(() => { + setIsMobileMenuOpen(false); + }, []); + return (
+ {/* Mobile menu toggle button - 44px min touch target */} + {/* Mobile backdrop overlay */}