diff --git a/src/app/wrapped/components/empty-state.tsx b/src/app/wrapped/components/empty-state.tsx
index 42ec57d1..df5e9381 100644
--- a/src/app/wrapped/components/empty-state.tsx
+++ b/src/app/wrapped/components/empty-state.tsx
@@ -68,15 +68,7 @@ export function WrappedEmptyState({
>
{isGlobal
? "W tym semestrze nie ma jeszcze aktywności, z której można złożyć globalne Wrapped."
- : "Dla tego semestru nie mamy danych do Twojego Wrapped, więc nie da się go już wygenerować za ten okres. Korzystaj z Testownika w następnym semestrze, a wtedy przygotujemy podsumowanie."}
- {isGlobal ? null : (
- <>
- {" "}
-
- Bez aktywności w danym semestrze nie mamy z czego go złożyć.
-
- >
- )}
+ : "Nie rozwiązywałeś żadnych quizów w tym semestrze, więc nie ma z czego złożyć Wrapped. Wróć tu po sesji w następnym."}
-
+
+ );
+}
+function WrappedLogin({
+ isGuest,
+ isGlobal,
+}: {
+ isGuest: boolean;
+ isGlobal: boolean;
+}) {
+ const redirectPath = `/wrapped${isGlobal ? "/global" : ""}`;
+ const title = isGuest
+ ? "Wrapped nie jest dostępne dla gości"
+ : "Zaloguj się\n do Wrapped";
+ const description = isGuest
+ ? "Ta funkcja jest dostępna tylko dla zalogowanych kont. Zaloguj się żeby kontynuować."
+ : isGlobal
+ ? "Zaloguj się, żeby zobaczyć semestr całego Testownika w liczbach."
+ : "Zaloguj się, żeby zobaczyć swoje semestralne podsumowanie w Testowniku.";
+
+ return (
+
+
+ >
+ {/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */}
+
+
+
+
+ Testownik Wrapped
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ {isGuest ? "Przejdź do logowania" : "Zaloguj się"}
+
);
}
export function WrappedExperience({ mode }: { mode: "user" | "global" }) {
- const { isAuthenticated } = useContext(AppContext);
+ const { checkPermission, user } = useContext(AppContext);
+ const canViewWrapped = checkPermission(PermissionAction.VIEW_WRAPPED);
// Theme settings: defaults < easter-egg (temporary React state).
const [easterSettings, setEasterSettings] =
@@ -109,12 +188,17 @@ export function WrappedExperience({ mode }: { mode: "user" | "global" }) {
mode === "global"
? getWrappedService().getGlobalWrapped()
: getWrappedService().getWrapped(),
- enabled: isAuthenticated,
+ enabled: canViewWrapped,
staleTime: 5 * 60 * 1000,
});
- if (!isAuthenticated) {
- return ;
+ if (!canViewWrapped) {
+ return (
+
+ );
}
if (isPending) {
diff --git a/src/app/wrapped/components/wrapped-story.tsx b/src/app/wrapped/components/wrapped-story.tsx
index f140fe1c..834b33a6 100644
--- a/src/app/wrapped/components/wrapped-story.tsx
+++ b/src/app/wrapped/components/wrapped-story.tsx
@@ -10,6 +10,7 @@ import { shareWrapped } from "../share-image";
import type { ShareTheme } from "../share-image";
import { SLIDE_REGISTRY } from "../slides";
import { isDark, slideColors } from "../theme";
+import { useImmersiveThemeColor } from "../use-immersive-theme-color";
import { useWrappedPlayer } from "../use-wrapped-player";
import type { WrappedSettings } from "../wrapped.config";
import { DEFAULT_DURATION, PALETTES, resolveSlides } from "../wrapped.config";
@@ -61,6 +62,10 @@ function StoryPlayer({
const Slide = SLIDE_REGISTRY[slideId];
const isEdgeSlide = index === 0 || index === count - 1;
+ // Edge slides render as a card over the app background, so the browser
+ // bars should keep their default tint there.
+ useImmersiveThemeColor(isEdgeSlide ? null : colors.bg);
+
const onShare = () => {
if (sharing.current) {
return;
diff --git a/src/app/wrapped/slides/index.tsx b/src/app/wrapped/slides/index.tsx
index 1c09a980..a083d27f 100644
--- a/src/app/wrapped/slides/index.tsx
+++ b/src/app/wrapped/slides/index.tsx
@@ -853,7 +853,7 @@ function HardestSlide({ data }: SlideProps) {
aria-hidden
style={{
position: "absolute",
- bottom: "10%",
+ bottom: "-2%",
left: "-3%",
transform: "rotate(6deg)",
pointerEvents: "none",
diff --git a/src/app/wrapped/use-immersive-theme-color.ts b/src/app/wrapped/use-immersive-theme-color.ts
new file mode 100644
index 00000000..cc251f4d
--- /dev/null
+++ b/src/app/wrapped/use-immersive-theme-color.ts
@@ -0,0 +1,80 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+/** Keep in step with the fullscreen overlay breakpoint in wrapped.css. */
+const IMMERSIVE_QUERY = "(max-width: 640px)";
+
+/** Let the card expand before Safari starts sampling the body tint. */
+const EDGE_EXPAND_MS = 150;
+
+const IOS_SAFARI_EXCLUDE = /(CriOS|FxiOS|EdgiOS|OPiOS|DuckDuckGo)/;
+
+function isIosSafari(): boolean {
+ const { maxTouchPoints, userAgent } = navigator;
+ const isIos =
+ /iP(hone|ad|od)/.test(userAgent) ||
+ (userAgent.includes("Macintosh") && maxTouchPoints > 1);
+
+ return (
+ isIos &&
+ userAgent.includes("Safari") &&
+ userAgent.includes("Version/") &&
+ !IOS_SAFARI_EXCLUDE.test(userAgent)
+ );
+}
+
+/**
+ * Safari caches tint samples from fixed overlays; the body background updates live.
+ * During immersive slides, mirror the active colour there so browser chrome follows.
+ */
+export function useImmersiveThemeColor(color: string | null): void {
+ // Delay only the first tint after entering immersive mode.
+ const applied = useRef(false);
+
+ useEffect(() => {
+ if (color === null || !isIosSafari()) {
+ applied.current = false;
+ return;
+ }
+ const media = window.matchMedia(IMMERSIVE_QUERY);
+ const root = document.documentElement;
+ const { body } = document;
+
+ const previous = {
+ root: root.style.backgroundColor,
+ body: body.style.backgroundColor,
+ };
+
+ const restore = () => {
+ root.style.backgroundColor = previous.root;
+ body.style.backgroundColor = previous.body;
+ body.classList.remove("wrapped-immersive-body");
+ };
+
+ const sync = () => {
+ if (media.matches) {
+ applied.current = true;
+ body.classList.add("wrapped-immersive-body");
+ root.style.backgroundColor = color;
+ body.style.backgroundColor = color;
+ } else {
+ applied.current = false;
+ restore();
+ }
+ };
+
+ const timeout = applied.current ? null : setTimeout(sync, EDGE_EXPAND_MS);
+ if (timeout === null) {
+ sync();
+ }
+ media.addEventListener("change", sync);
+ return () => {
+ if (timeout !== null) {
+ clearTimeout(timeout);
+ }
+ media.removeEventListener("change", sync);
+ restore();
+ };
+ }, [color]);
+}
diff --git a/src/app/wrapped/wrapped.css b/src/app/wrapped/wrapped.css
index 965178ef..e2c7e5a9 100644
--- a/src/app/wrapped/wrapped.css
+++ b/src/app/wrapped/wrapped.css
@@ -144,6 +144,15 @@
width: 100%;
}
+/* Applied to while the fullscreen story is active so the Safari
+ toolbar tint (sampled from the body colour) fades in step with the stage.
+ The layout's gradient background-image would paint over the synced
+ background-color, so it is suppressed here. */
+body.wrapped-immersive-body {
+ background-image: none !important;
+ transition: background-color 0.45s ease;
+}
+
/* Mobile: middle story slides use the immersive fixed overlay. Intro/outro use
`.is-edge` to pull into a card, and the no-activity state uses its own card
class so it does not trap the rest of the app behind it. */
@@ -154,10 +163,10 @@
}
.wrapped-stage {
position: fixed !important;
- top: 0 !important;
- left: 0 !important;
- right: 0 !important;
- bottom: 0 !important;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
z-index: 50 !important;
margin: 0 !important;
border-radius: 0 !important;
@@ -171,12 +180,19 @@
background-color 0.45s ease,
color 0.45s ease;
}
+ @supports (-webkit-touch-callout: none) {
+ .wrapped-stage:not(.is-edge) {
+ /* iOS Safari tint workaround: leave tiny body-sampled strips. */
+ top: 6px;
+ bottom: 5px;
+ }
+ }
.wrapped-stage.is-edge {
- top: 4.75rem !important;
- left: 0.75rem !important;
+ top: 4.75rem;
+ left: 0.75rem;
z-index: 10 !important;
- right: 0.75rem !important;
- bottom: 0.75rem !important;
+ right: 0.75rem;
+ bottom: 0.75rem;
border-radius: 22px !important;
box-shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.5) !important;
}
diff --git a/src/hooks/use-auto-guest.ts b/src/hooks/use-auto-guest.ts
index ea40d96f..684ca65c 100644
--- a/src/hooks/use-auto-guest.ts
+++ b/src/hooks/use-auto-guest.ts
@@ -12,6 +12,7 @@ const GUEST_EXCLUDED_ROUTES = [
"/oauth",
"/login-otp",
"/privacy-policy",
+ "/wrapped",
];
export function useAutoGuest(user: JWTPayload | null) {
diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts
index a65f1db8..951e792c 100644
--- a/src/lib/auth/permissions.ts
+++ b/src/lib/auth/permissions.ts
@@ -22,6 +22,8 @@ export enum PermissionAction {
AI_FEATURES = "ai_features",
// Viewing quiz statistics
VIEW_QUIZ_STATS = "view_quiz_stats",
+ // Viewing Testownik Wrapped summaries
+ VIEW_WRAPPED = "view_wrapped",
}
export const PERMISSIONS_BY_ROLE: Record<
@@ -42,6 +44,7 @@ export const PERMISSIONS_BY_ROLE: Record<
PermissionAction.QUIZ_CONTINUITY,
PermissionAction.AI_FEATURES,
PermissionAction.VIEW_QUIZ_STATS,
+ PermissionAction.VIEW_WRAPPED,
],
[ACCOUNT_TYPE.STUDENT]: [
PermissionAction.BROWSE_PUBLIC_QUIZZES,
@@ -54,6 +57,7 @@ export const PERMISSIONS_BY_ROLE: Record<
PermissionAction.QUIZ_CONTINUITY,
PermissionAction.AI_FEATURES,
PermissionAction.VIEW_QUIZ_STATS,
+ PermissionAction.VIEW_WRAPPED,
],
[ACCOUNT_TYPE.LECTURER]: [
PermissionAction.VIEW_SHARED_QUIZZES,
@@ -64,6 +68,7 @@ export const PERMISSIONS_BY_ROLE: Record<
PermissionAction.QUIZ_CONTINUITY,
PermissionAction.AI_FEATURES,
PermissionAction.VIEW_QUIZ_STATS,
+ PermissionAction.VIEW_WRAPPED,
],
};