Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions public/icons/courses/pencil.line.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions public/icons/courses/text.book.closed.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions public/meta/course-match.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@
],
"icon": "/icons/courses/cs.svg",
"color": "#00a3ff"
},
{
"subject": "Literature",
"keywords": [
{
"keyword": "Literature",
"weight": 1
}
],
"icon": "/icons/courses/text.book.closed.svg",
"color": "#3662e3"
}
],
"catchAll": {
Expand Down
7 changes: 7 additions & 0 deletions src/app/mobile/onboarding/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client';

import { OnboardingController } from '@/components/onboarding/OnboardingController';

export default function MobileOnboardingPage() {
return <OnboardingController mode="mobile" />;
}
2 changes: 1 addition & 1 deletion src/app/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
import { OnboardingController } from '@/components/onboarding/OnboardingController';

export default function OnboardingPage() {
return <OnboardingController />;
return <OnboardingController mode="web" />;
}
11 changes: 8 additions & 3 deletions src/components/LayoutWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ type AuthState = {
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [authState, setAuthState] = useState<AuthState>(null);
const isHelpRoute = pathname.startsWith('/help');
const isMobileOnboardingRoute = pathname.startsWith('/mobile/onboarding');

useEffect(() => {
// Skip auth check for help/docs routes
if (pathname.startsWith('/help')) {
if (isHelpRoute) {
return;
}

Expand All @@ -40,10 +42,10 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
};

checkAuth();
}, [pathname]);
}, [isHelpRoute, pathname]);

// Bypass auth check for help/docs routes
if (pathname.startsWith('/help')) {
if (isHelpRoute) {
return <>{children}</>;
}

Expand All @@ -54,6 +56,9 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {

// If authenticated
if (authState.isAuthenticated) {
if (isMobileOnboardingRoute) {
return <>{children}</>;
}
// Check if onboarding is incomplete
if (authState.onboardingStep && authState.onboardingStep !== 'completed') {
return <OnboardingController />;
Expand Down
21 changes: 18 additions & 3 deletions src/components/onboarding/ConnectLmsStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { OnboardingSlide } from './OnboardingSlide';
import { mobileBridge } from '@/lib/mobileBridge';

const BACKGROUND_COLOR = '#2563eb';

export function ConnectLmsStep() {
type OnboardingMode = 'web' | 'mobile';

interface ConnectLmsStepProps {
mode?: OnboardingMode;
}

export function ConnectLmsStep({ mode = 'web' }: ConnectLmsStepProps) {
const searchParams = useSearchParams();
const error = searchParams.get('error');
const [overrideOpen, setOverrideOpen] = useState(false);
Expand All @@ -22,7 +29,13 @@ export function ConnectLmsStep() {
};
}, []);

const handleConnect = () => {
const handleConnect = async () => {
if (mode === 'mobile') {
const bridged = await mobileBridge.startSchoologyOAuth();
if (bridged) {
return;
}
}
window.location.href = `${process.env.NEXT_PUBLIC_BACKEND_URL}/oauth/schoology/start`;
};

Expand Down Expand Up @@ -87,7 +100,9 @@ export function ConnectLmsStep() {
</p>
)}
<button
onClick={handleConnect}
onClick={() => {
void handleConnect();
}}
className="px-10 py-4 bg-white text-blue-600 rounded-full font-semibold text-lg shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-200 cursor-pointer"
>
Connect Schoology
Expand Down
53 changes: 48 additions & 5 deletions src/components/onboarding/OnboardingController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,39 @@ import { api } from '../../../convex/_generated/api';
import { WelcomeStep } from './WelcomeStep';
import { ConnectLmsStep } from './ConnectLmsStep';
import { SmartConsentStep } from './SmartConsentStep';
import { mobileBridge } from '@/lib/mobileBridge';

export function OnboardingController() {
type OnboardingMode = 'web' | 'mobile';

interface OnboardingControllerProps {
mode?: OnboardingMode;
}

export function OnboardingController({ mode = 'web' }: OnboardingControllerProps) {
const user = useQuery(api.users.getUser);

// Redirect to dashboard when onboarding completes
useEffect(() => {
if (user?.onboardingStep === 'completed') {
window.location.href = '/dashboard';
if (user?.onboardingStep !== 'completed') {
return;
}
}, [user?.onboardingStep]);

let cancelled = false;
const finalizeOnboarding = async () => {
if (mode === 'mobile') {
const bridged = await mobileBridge.onboardingComplete();
if (bridged || cancelled) {
return;
}
}
window.location.href = '/dashboard';
};

void finalizeOnboarding();
return () => {
cancelled = true;
};
}, [mode, user?.onboardingStep]);

// Loading state
if (user === undefined) {
Expand All @@ -27,14 +50,34 @@ export function OnboardingController() {
}

if (user === null) {
if (mode === 'mobile') {
return (
<div className="fixed inset-0 bg-[#0f172a] text-white flex items-center justify-center px-6">
<div className="max-w-md text-center space-y-4">
<h1 className="text-3xl font-semibold tracking-tight">Session Expired</h1>
<p className="text-base opacity-80">
This onboarding session is no longer authenticated. Return to the app and restart onboarding.
</p>
<button
onClick={() => {
window.location.href = '/';
}}
className="px-8 py-3 bg-white text-slate-900 rounded-full font-semibold text-base shadow-lg hover:shadow-xl hover:scale-105 transition-all duration-200 cursor-pointer"
>
Return Home
</button>
</div>
</div>
);
}
return null;
}

switch (user.onboardingStep) {
case 'welcome':
return <WelcomeStep />;
case 'connect_lms':
return <ConnectLmsStep />;
return <ConnectLmsStep mode={mode} />;
case 'smart_consent':
return <SmartConsentStep />;
case 'completed':
Expand Down
80 changes: 80 additions & 0 deletions src/lib/mobileBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export type MobileBridgeContext = {
platform?: string;
appVersion?: string;
buildNumber?: string;
bridgeVersion?: string;
[key: string]: unknown;
};

type MobileBridgeV1 = {
startSchoologyOAuth: () => void | Promise<void>;
openExternalURL: (url: string) => void | Promise<void>;
onboardingComplete: () => void | Promise<void>;
getContext: () => MobileBridgeContext | Promise<MobileBridgeContext>;
};

type PartialMobileBridgeV1 = Partial<MobileBridgeV1>;

function getBridge(): PartialMobileBridgeV1 | null {
if (typeof window === 'undefined') {
return null;
}
return window.mobileBridge?.v1 ?? null;
}

export const mobileBridge = {
isAvailable(): boolean {
return getBridge() !== null;
},

async startSchoologyOAuth(): Promise<boolean> {
const bridge = getBridge();
if (!bridge?.startSchoologyOAuth) {
return false;
}
try {
await bridge.startSchoologyOAuth();
return true;
} catch {
return false;
}
},

async openExternalURL(url: string): Promise<boolean> {
const bridge = getBridge();
if (!bridge?.openExternalURL) {
return false;
}
try {
await bridge.openExternalURL(url);
return true;
} catch {
return false;
}
},

async onboardingComplete(): Promise<boolean> {
const bridge = getBridge();
if (!bridge?.onboardingComplete) {
return false;
}
try {
await bridge.onboardingComplete();
return true;
} catch {
return false;
}
},

async getContext(): Promise<MobileBridgeContext | null> {
const bridge = getBridge();
if (!bridge?.getContext) {
return null;
}
try {
return await bridge.getContext();
} catch {
return null;
}
},
};
16 changes: 16 additions & 0 deletions src/types/mobileBridge.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { MobileBridgeContext } from '@/lib/mobileBridge';

declare global {
interface Window {
mobileBridge?: {
v1?: {
startSchoologyOAuth?: () => void | Promise<void>;
openExternalURL?: (url: string) => void | Promise<void>;
onboardingComplete?: () => void | Promise<void>;
getContext?: () => MobileBridgeContext | Promise<MobileBridgeContext>;
};
};
}
}

export {};