diff --git a/apps/web/src/api/endpoint.ts b/apps/web/src/api/endpoint.ts index 20c5f1b8fa..765d04d834 100644 --- a/apps/web/src/api/endpoint.ts +++ b/apps/web/src/api/endpoint.ts @@ -6,3 +6,4 @@ export const endpointBaseUrl = : `https://api.${serloDomain}` export const endpoint = `${endpointBaseUrl}/graphql` +export const endpointEnmeshed = `${endpointBaseUrl}/enmeshed` diff --git a/apps/web/src/components/auth/flow.tsx b/apps/web/src/components/auth/flow.tsx index 86afbf82d8..d9acc2a8af 100644 --- a/apps/web/src/components/auth/flow.tsx +++ b/apps/web/src/components/auth/flow.tsx @@ -28,13 +28,7 @@ import { changeButtonTypeOfSSOProvider, sortKratosUiNodes, } from '../pages/auth/ory-helper' -import { - filterUnwantedRedirection, - loginUrl, - registrationUrl, - VALIDATION_ERROR_TYPE, - verificationUrl, -} from '../pages/auth/utils' +import { VALIDATION_ERROR_TYPE } from '../pages/auth/utils' import { checkLoggedIn } from '@/auth/cookie/check-logged-in' import { fetchAndPersistAuthSession } from '@/auth/cookie/fetch-and-persist-auth-session' import type { AxiosError } from '@/auth/types' @@ -79,15 +73,13 @@ export function Flow({ const [values, setValues] = useState>(() => { const values: Record = {} filteredNodes.forEach((node) => { - if (isUiNodeInputAttributes(node.attributes)) { - if ( - node.attributes.type === 'hidden' || - node.attributes.type === 'submit' || - node.attributes.type === 'button' || - node.attributes.type === 'checkbox' - ) { - values[getNodeId(node)] = node.attributes.value - } + if ( + isUiNodeInputAttributes(node.attributes) && + ['hidden', 'submit', 'button', 'checkbox'].includes( + node.attributes.type + ) + ) { + values[getNodeId(node)] = node.attributes.value } }) return values @@ -198,13 +190,8 @@ export function handleFlowError( case 'session_already_available': { if (!checkLoggedIn()) void fetchAndPersistAuthSession() showToastNotice(strings.notices.alreadyLoggedIn, 'default', 3000) - - const redirection = filterUnwantedRedirection({ - desiredPath: sessionStorage.getItem('previousPathname'), - unwantedPaths: [verificationUrl, loginUrl, registrationUrl], - }) setTimeout(() => { - window.location.href = redirection + window.location.href = 'https://journey.serlo-staging.dev/willkommen' }, 3000) return diff --git a/apps/web/src/components/content/entity.tsx b/apps/web/src/components/content/entity.tsx index 92a6f55092..e0c3584cbd 100644 --- a/apps/web/src/components/content/entity.tsx +++ b/apps/web/src/components/content/entity.tsx @@ -12,6 +12,7 @@ import { import { Router } from 'next/router' import { useState, MouseEvent } from 'react' +import { MockupGaps } from './exercises/mockup-gaps' import { HSpace } from './h-space' import { Link } from './link' import { FaIcon } from '../fa-icon' @@ -51,6 +52,9 @@ export function Entity({ data }: EntityProps) { }) const { strings } = useInstanceData() + + const isMockupCoursePage = data.id === 244386 + return wrapWithSchema( <> {renderCourseNavigation()} @@ -59,7 +63,11 @@ export function Entity({ data }: EntityProps) { {renderStyledH1()} {renderUserTools({ aboveContent: true })}
- {data.content && renderContent(data.content)} + {isMockupCoursePage ? ( + + ) : ( + data.content && renderContent(data.content) + )}
{renderCourseFooter()} diff --git a/apps/web/src/components/content/exercises/mockup-gaps.tsx b/apps/web/src/components/content/exercises/mockup-gaps.tsx new file mode 100644 index 0000000000..fe943a6bb4 --- /dev/null +++ b/apps/web/src/components/content/exercises/mockup-gaps.tsx @@ -0,0 +1,129 @@ +import { faSave } from '@fortawesome/free-regular-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import nProgress from 'nprogress' +import { useState } from 'react' + +import { endpointEnmeshed } from '@/api/endpoint' +import { showToastNotice } from '@/helper/show-toast-notice' +import { triggerSentry } from '@/helper/trigger-sentry' +import { GapEx, Gappy } from '@/pages/___gaps' + +export function MockupGaps() { + const [showWalletNotice, setShowWalletNotice] = useState(false) + const [success, setSuccess] = useState(false) + + function onFeedbackHandler(success: boolean) { + setShowWalletNotice(true) + setSuccess(success) + } + + /* + Wähle die richtigen Begriffe für die Lücken. + Das [logistische] Wachstum verläuft in drei Phasen: + Zunächst wächst die Größe [exponentiell] und der Graph steigt [stark]. + In der zweiten Phase ist das Wachstum [annähernd linear]. In diesem Bereich liegt auch [der Wendepunkt]. + Der Graph [steigt] in der dritten Phase [schwach]. Das Wachstum ist [beschränkt]. + [die Asymptote] [kubisch] [fällt] [logarithmische] + */ + + return ( + <> + +
+

+ Wähle die richtigen Begriffe für die Lücken. +

+

+ Das Wachstum verläuft in drei Phasen: +

+

+ Zunächst wächst die Größe und der Graph steigt{' '} + . +

+

+ In der zweiten Phase ist das Wachstum .
In + diesem Bereich liegt auch . +

+

+ Der Graph in der dritten Phase{' '} + + . Das Wachstum ist . +

+
+
+ {showWalletNotice ? ( +
+ {success + ? 'Super, du hast den Kurs erfolgreich durchgearbeitet! ' + : 'Yeah, du hast den Kurs durchgearbeitet. '} + <> + Du kannst deinen Lernfortschritt jetzt speichern. +
+ {!success && 'Oder du probierst dich noch mal an der Übung'} +
+ + +
+ ) : null} + + ) + + function saveLearningProgress() { + const sessionId = sessionStorage.getItem('sessionId') + const name = 'LernstandMathe' + const value = encodeURIComponent('✓ Logistisches Wachstum') + + if (!sessionId) return + + nProgress.start() + + fetch( + `${endpointEnmeshed}/attributes?name=${name}&value=${value}&sessionId=${sessionId}`, + { method: 'POST' } + ) + .then((res) => res.json()) + .then(() => { + setTimeout(() => { + nProgress.done() + showToastNotice( + '👌 Lernstand wurde erfolgreich an deine Wallet gesendet', + 'success', + 6000 + ) + setShowWalletNotice(false) + }, 540) + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.log(JSON.stringify(e)) + triggerSentry({ + message: `Error in User-Journey: Saving Attribute: ${JSON.stringify( + e + )}`, + }) + setShowWalletNotice(false) + }) + } +} diff --git a/apps/web/src/components/pages/auth/login.tsx b/apps/web/src/components/pages/auth/login.tsx index fc318d59f9..fea708b124 100644 --- a/apps/web/src/components/pages/auth/login.tsx +++ b/apps/web/src/components/pages/auth/login.tsx @@ -3,14 +3,7 @@ import { useRouter } from 'next/router' import { useEffect, useRef, useState } from 'react' import { changeButtonTypeOfSSOProvider, sortKratosUiNodes } from './ory-helper' -import { - filterUnwantedRedirection, - loginUrl, - logoutUrl, - registrationUrl, - verificationUrl, - recoveryUrl, -} from './utils' +import { registrationUrl, recoveryUrl } from './utils' import { getAuthPayloadFromSession } from '@/auth/auth-provider' import { fetchAndPersistAuthSession } from '@/auth/cookie/fetch-and-persist-auth-session' import { kratos } from '@/auth/kratos' @@ -152,11 +145,6 @@ export function Login({ oauth }: { oauth?: boolean }) { async function onLogin(values: UpdateLoginFlowBody) { if (!flow?.id) return - const redirection = filterUnwantedRedirection({ - desiredPath: sessionStorage.getItem('previousPathname'), - unwantedPaths: [verificationUrl, logoutUrl, loginUrl, recoveryUrl], - }) - try { await kratos .updateLoginFlow({ flow: flow.id, updateLoginFlowBody: values }) @@ -168,7 +156,7 @@ export function Login({ oauth }: { oauth?: boolean }) { showToastNotice( strings.notices.welcome.replace('%username%', username) ) - void router.push(flow.return_to ?? redirection) + window.location.href = 'https://journey.serlo-staging.dev/willkommen' return }) } catch (e: unknown) { diff --git a/apps/web/src/components/pages/data-wallet-journey.tsx b/apps/web/src/components/pages/data-wallet-journey.tsx new file mode 100644 index 0000000000..28b082068e --- /dev/null +++ b/apps/web/src/components/pages/data-wallet-journey.tsx @@ -0,0 +1,166 @@ +import clsx from 'clsx' +import { SetStateAction, useState } from 'react' + +import { HeadTags } from '../head-tags' +import { PartnerList } from '../landing/rework/partner-list' +import { Logo } from '../navigation/header/logo' +import { endpointEnmeshed } from '@/api/endpoint' +import { LoadingSpinner } from '@/components/loading/loading-spinner' +import { triggerSentry } from '@/helper/trigger-sentry' + +export function createQRCode( + stateSetter: (value: SetStateAction) => void +) { + stateSetter('loading') + fetch(`${endpointEnmeshed}/init`, { + method: 'POST', + headers: { + Accept: 'image/png', + }, + }) + .then((res) => res.blob()) + .then((res) => { + const urlCreator = window.URL || window.webkitURL + stateSetter(urlCreator.createObjectURL(res)) + // TODO: When the workflow has been defined in the future we should revoke the object URL when done with: + // urlCreator.revokeObjectUrl(qrCode) + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.log(JSON.stringify(e)) + + triggerSentry({ + message: `error while creating qr code: ${JSON.stringify(e)}`, + }) + }) +} + +export function DataWallet() { + const [qrCode, setQrCode] = useState('') + + return ( + <> + +
+
+ +
+
+ +
+
+

+ Deine Daten. Deine Rechte. +

+

+ Wir gehen verantwortungsvoll mit deinen Daten um. Serlo ist dafür + Projektpartner vom Mein Bildungsraum. Mein Bildungsraum setzt eine + sogenannte „Data Wallet“ ein, die es dir ermöglicht, deine Daten + sicher und direkt mit uns zu teilen. +

+

+ Die Data Wallet ist auf deinem Smartphone, dort sind deine Daten + sicher aufgehoben. Um Serlo dein Vertrauen zu schenken, ist eine + einmalige Einrichtung erforderlich: +

+
    +
  1. + Lade dir die App{' '} + + Mein Bildungsraum: Wallet + {' '} + auf dein Smartphone. +
  2. +
  3. + Erstelle einen{' '} + createQRCode(setQrCode)} + > + QR-Code + + . + {qrCode && + ((qrCode === 'loading' && ) || ( + + ))} +
  4. +
  5. + Lege in deiner Wallet-App ein neues Profil an und füge Serlo als + Kontakt hinzu, indem du den QR-Code scannst. +
  6. +
  7. + Sobald der Kontakt verifiziert wurde, erhältst du innerhalb + weniger Minuten eine Benachrichtigung in deiner Wallet-App. +
  8. +
+

+ Sollten dabei Probleme auftauchen,{' '} + + kontaktiere + {' '} + uns gern. +

+ +
+
+ +
+

+ Partner und Unterstützer +

+ + + +
+ + ) +} diff --git a/apps/web/src/components/pages/lenabi-welcome.tsx b/apps/web/src/components/pages/lenabi-welcome.tsx new file mode 100644 index 0000000000..210b7eb3f3 --- /dev/null +++ b/apps/web/src/components/pages/lenabi-welcome.tsx @@ -0,0 +1,195 @@ +import clsx from 'clsx' +import React, { ReactElement, useState, useEffect } from 'react' + +import { WelcomeModal } from './user/welcome-modal' +import { Link } from '../content/link' +import { HeadTags } from '../head-tags' +import { LandingSubjectsNew } from '../landing/rework/landing-subjects-new' +import { useAuthentication } from '@/auth/use-authentication' +import { LandingSubjectsData } from '@/data-types' + +const landingSubjectsData: LandingSubjectsData = { + subjects: [ + { url: '/mathe', title: 'Mathematik', icon: 'math' }, + { + url: '/nachhaltigkeit', + title: 'Nachhaltigkeit', + icon: 'sustainability', + }, + { url: '/biologie', title: 'Biologie', icon: 'biology' }, + { url: '/chemie', title: 'Chemie', icon: 'chemistry' }, + { url: '/informatik', title: 'Informatik', icon: 'informatics' }, + { + url: '/community/neue-fächer-themen', + title: 'Fächer im Aufbau', + icon: 'new', + }, + ], + additionalLinks: [], +} + +export function LenabiWelcome() { + const [learnDataLoaded, setLearnDataLoaded] = useState(false) + const auth = useAuthentication() + const [sessionId, setSessionId] = useState(undefined) + + useEffect(() => { + const sessionId = createRandomSessionId() + setSessionId(sessionId) + sessionStorage.setItem('sessionId', sessionId) + }, []) + + if (!auth) return <>bitte einloggen + + return ( + <> + +
+
+

+ Willkommen {auth.username}! 👋 +

+

+ Was möchtest du lernen ? +

+

+ Mit dir lernen gerade 1621 andere + Menschen auf Serlo. +

+
+ +
+

+ Lernempfehlungen +

+ +
+ {learnDataLoaded ? ( + renderLearnData() + ) : ( +
+

+ Wenn du die Lerndaten aus deiner Data-Wallet für Serlo + freigibts, +
+ kannst du hier deine aktuelle Lernempfehlungen und Aufgaben + sehen. +

+ {sessionId && ( + setLearnDataLoaded(true)} + username={auth.username} + sessionId={sessionId} + /> + )} +
+ )} +
+
+ +
+

+ Nach Fächern +

+ +
+ +
+

+ Nach Lehrplan +

+
+ + Realschule + {' '} + + Mittelschule (Hauptschule) + {' '} + + FOS & BOS + {' '} + + Hochschule + {' '} + + Prüfungen + +
+
+
+ + + ) + + function renderLearnData() { + if (!learnDataLoaded) return null + + return ( + <> + {renderBox({ + title: 'Logistisches Wachstum', + content: ( +

+ Jetzt zum Kurs +
+ (erstellt von Lehrkraft) +

+ ), + link: '/244309', + })} + + ) + } + + function renderBox({ + title, + content, + link, + }: { + title: string + content: ReactElement + link?: string + }) { + return ( + +

{title}

+ {content} + + ) + } +} + +function createRandomSessionId() { + return (Math.random() + 1).toString(36).substring(2, 13) +} diff --git a/apps/web/src/components/pages/user/welcome-modal.tsx b/apps/web/src/components/pages/user/welcome-modal.tsx new file mode 100644 index 0000000000..527f02d73e --- /dev/null +++ b/apps/web/src/components/pages/user/welcome-modal.tsx @@ -0,0 +1,194 @@ +import Head from 'next/head' +import { MouseEvent, useState } from 'react' + +import { endpointEnmeshed } from '@/api/endpoint' +import { LoadingSpinner } from '@/components/loading/loading-spinner' +import { ModalWithCloseButton } from '@/components/modal-with-close-button' +import { triggerSentry } from '@/helper/trigger-sentry' + +export function WelcomeModal({ + callback, + username, + sessionId, +}: { + callback: () => void + username: string + sessionId: string +}) { + const [showModal, setShowModal] = useState(false) + + const [qrCodeSrc, setQrCodeSrc] = useState('') + + const handleOnClick = (event: MouseEvent) => { + event.preventDefault() + setShowModal(true) + fetchQRCode() + } + + const handleMockLoad = () => { + setTimeout(() => { + setShowModal(false) + callback() + }, 500) + } + + return ( + <> + + + + + + + + + + setShowModal(false)} + title="Eigenen Lernstand laden" + > +

+ Hier kannst du deinen Lernstand aus deiner Data-Wallet laden. Wenn du + das noch nie gemacht hast, wir eine{' '} + + ausführlichere Anleitung + {' '} + für dich. +

+ + QR-Code zum freischalten + {qrCodeSrc === '' ? ( +
+ +
+ ) : ( +

+ +

+ )} +

+ Nachdem du den Code mit der Wallet-App gescannt hast erscheint hier + gleich dein Lernstand + +

+ +
+ + ) + + function fetchQRCode() { + const name = encodeURIComponent(username) + + fetch(`${endpointEnmeshed}/init?sessionId=${sessionId}&name=${name}`, { + method: 'POST', + headers: { + Accept: 'image/png', + }, + }) + .then((res) => res.blob()) + .then((res) => { + const urlCreator = window.URL || window.webkitURL + setQrCodeSrc(urlCreator.createObjectURL(res)) + // TODO: When the workflow has been defined in the future we should revoke the object URL when done with: + // urlCreator.revokeObjectUrl(qrCode) + }) + .then(fetchAttributes) + .catch((e) => { + // eslint-disable-next-line no-console + console.log(JSON.stringify(e)) + + triggerSentry({ + message: `Error in User-Journey: Reading QR-Code: ${JSON.stringify( + e + )}`, + }) + + setShowModal(false) + callback() + }) + } + + function fetchAttributes() { + fetch(`${endpointEnmeshed}/attributes?sessionId=${sessionId}`, {}) + .then((res) => res.json()) + .then((body: EnmeshedResponse) => { + if (body.status === 'pending') { + // eslint-disable-next-line no-console + console.log('INFO: RelationshipRequest is pending...') + setTimeout(fetchAttributes, 1000) + } + if (body.status === 'success') { + // eslint-disable-next-line no-console + console.log('INFO: RelationshipRequest was accepted.') + setShowModal(false) + callback() + } + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.log(`ERROR: ${JSON.stringify(e)}`) + setShowModal(false) + callback() + triggerSentry({ + message: `Error in User-Journey: Reading Attributes: ${JSON.stringify( + e + )}`, + }) + }) + } +} + +export type EnmeshedResponse = + | EnmeshedErrorResponse + | EnmeshedPendingResponse + | EnmeshedSuccessResponse + +export interface EnmeshedErrorResponse { + status: 'error' + message: string +} + +export interface EnmeshedPendingResponse { + status: 'pending' +} + +export interface EnmeshedSuccessResponse { + status: 'success' + attributes: Record +} diff --git a/apps/web/src/pages/___gaps.tsx b/apps/web/src/pages/___gaps.tsx new file mode 100644 index 0000000000..832c273d2e --- /dev/null +++ b/apps/web/src/pages/___gaps.tsx @@ -0,0 +1,333 @@ +import { Feedback } from '@editor/plugins/sc-mc-exercise/renderer/feedback' +import clsx from 'clsx' +import React, { + createContext, + useState, + useContext, + ReactNode, + Fragment, + useEffect, +} from 'react' + +import { FrontendClientBase } from '@/components/frontend-client-base' +import { renderedPageNoHooks } from '@/helper/rendered-page' +import { shuffleArray } from '@/helper/shuffle-array' + +export default renderedPageNoHooks(() => ( + + + +)) + +function Content() { + return <>nope +} + +interface GapProps { + mode: + | 'inactive' + | 'selected' + | 'filled' + | 'filled-selected' + | 'right' + | 'wrong' + | 'choice' + | 'choice-inactive' + text?: string + onClick?: () => void +} + +function Gap({ mode, text, onClick }: GapProps) { + if (mode === 'inactive') { + return ( + + ) + } + if (mode === 'selected') { + return ( + + ) + } + if (mode === 'choice') { + return ( + + {text} + + ) + } + if (mode === 'choice-inactive') { + return ( + + {text} + + ) + } + if (mode === 'filled') { + return ( + + {text} + + ) + } + if (mode === 'filled-selected') { + return ( + + {text} + + ) + } + if (mode === 'right') { + return ( + + {text} + + ) + } + if (mode === 'wrong') { + return ( + + {text} + + ) + } + return null +} + +interface GapContext { + selected: number + choices: string[] + filled: number[] + checked: boolean + select: (i: number) => void + deselect: (i: number) => void +} + +const GapContext = createContext(null) + +interface GapExProps { + choices: string[] + children: ReactNode + count?: number + onFeedback?: (success: boolean) => void +} + +export function GapEx({ choices, children, count, onFeedback }: GapExProps) { + const gapCount = count ?? choices.length + + const [selected, setSelected] = useState(-1) + const [filled, setFilled] = useState(Array(gapCount).fill(-1)) + const [checked, setChecked] = useState(false) + + const [sortedChoices, setSortedChoices] = useState(() => { + const copy = choices.slice(0) + copy.sort() + return copy + }) + + useEffect(() => { + setSortedChoices(shuffleArray(sortedChoices)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + function select(i: number) { + setSelected(i) + } + + function deselect(i: number) { + setSelected(i) + const filledCopy = filled.slice(0) + filledCopy[i] = -1 + setFilled(filledCopy) + } + + const alright = checked && filled.every((v, i) => v === i) + + const allfilled = filled.every((i) => i >= 0) + + return ( + + {children} +

+ {sortedChoices.map((c) => { + return ( + + choices[i] === c) || allfilled + ? 'choice-inactive' + : 'choice' + } + onClick={() => { + if (selected === -1 && allfilled) return + + let toFill = selected + if (toFill < 0) { + toFill = 0 + while (filled[toFill] >= 0) toFill++ + } + + const filledCopy = filled.slice(0) + const choiceIndex = choices.indexOf(c) + if (filledCopy.indexOf(choiceIndex) >= 0) { + filledCopy[filledCopy.indexOf(choiceIndex)] = -1 + } + filledCopy[toFill] = choiceIndex + + setFilled(filledCopy) + + if (allfilled) { + if (filledCopy.every((x) => x >= 0)) { + setSelected(-1) + return + } + } + + let next = toFill + do { + next++ + if (next >= gapCount) { + next = 0 + } + if (next === toFill) { + setSelected(-1) + return + } + } while (filledCopy[next] >= 0) + setSelected(next) + }} + /> + + ) + })} +

+ {checked && ( +
+ {' '} + + {alright ? ( + <> Super! Du hast die Aufgabe richtig gelöst!  + ) : ( + <> Leider noch nicht richtig. Versuch es nochmal!  + )} + +
+ )} + +
+ ) +} + +interface GappyProps { + index: number +} + +export function Gappy({ index }: GappyProps) { + const context = useContext(GapContext) + + if (context) { + if (context.checked) { + return ( + + ) + } else { + if (context.selected === index && context.filled[index] < 0) { + return + } else if (context.filled[index] >= 0) { + return ( + { + context.select(index) + }} + /> + ) + } else { + return ( + { + context.select(index) + }} + /> + ) + } + } + } + + return <>Fehler +} diff --git a/apps/web/src/pages/willkommen.tsx b/apps/web/src/pages/willkommen.tsx new file mode 100644 index 0000000000..ea26d0836c --- /dev/null +++ b/apps/web/src/pages/willkommen.tsx @@ -0,0 +1,9 @@ +import { FrontendClientBase } from '@/components/frontend-client-base' +import { LenabiWelcome } from '@/components/pages/lenabi-welcome' +import { renderedPageNoHooks } from '@/helper/rendered-page' + +export default renderedPageNoHooks(() => ( + + + +))