Compare commits
2 Commits
e75233929a
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 79fc2eacdc | |||
| 9074b17a83 |
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import type { InventoryItem, ActiveEffect } from "../types/quest";
|
import type { InventoryItem, ActiveEffect } from "../types/quest";
|
||||||
import {
|
import {
|
||||||
@ -43,7 +44,6 @@ const STYLES = `
|
|||||||
to { transform: translateY(0); opacity:1; }
|
to { transform: translateY(0); opacity:1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sea shimmer bg */
|
|
||||||
.inv-sheet::before {
|
.inv-sheet::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
||||||
@ -58,7 +58,6 @@ const STYLES = `
|
|||||||
100% { background-position: 100% 100%, 0% 100%; }
|
100% { background-position: 100% 100%, 0% 100%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gold orb top-right */
|
|
||||||
.inv-sheet::after {
|
.inv-sheet::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute; top: -60px; right: -40px; z-index: 0;
|
position: absolute; top: -60px; right: -40px; z-index: 0;
|
||||||
@ -67,7 +66,6 @@ const STYLES = `
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Handle ── */
|
|
||||||
.inv-handle-row {
|
.inv-handle-row {
|
||||||
display: flex; justify-content: center;
|
display: flex; justify-content: center;
|
||||||
padding: 0.75rem 0 0; flex-shrink: 0; position: relative; z-index: 2;
|
padding: 0.75rem 0 0; flex-shrink: 0; position: relative; z-index: 2;
|
||||||
@ -77,7 +75,6 @@ const STYLES = `
|
|||||||
background: rgba(255,255,255,0.1);
|
background: rgba(255,255,255,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ── */
|
|
||||||
.inv-header {
|
.inv-header {
|
||||||
position: relative; z-index: 2;
|
position: relative; z-index: 2;
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
@ -108,7 +105,6 @@ const STYLES = `
|
|||||||
background: rgba(251,191,36,0.1);
|
background: rgba(251,191,36,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Active effects banner ── */
|
|
||||||
.inv-active-bar {
|
.inv-active-bar {
|
||||||
position: relative; z-index: 2;
|
position: relative; z-index: 2;
|
||||||
display: flex; gap: 0.5rem; overflow-x: auto; scrollbar-width: none;
|
display: flex; gap: 0.5rem; overflow-x: auto; scrollbar-width: none;
|
||||||
@ -140,7 +136,6 @@ const STYLES = `
|
|||||||
margin-left: 0.1rem;
|
margin-left: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Divider ── */
|
|
||||||
.inv-divider {
|
.inv-divider {
|
||||||
position: relative; z-index: 2;
|
position: relative; z-index: 2;
|
||||||
height: 1px; margin: 0.85rem 1.3rem 0;
|
height: 1px; margin: 0.85rem 1.3rem 0;
|
||||||
@ -154,7 +149,6 @@ const STYLES = `
|
|||||||
text-transform: uppercase; color: rgba(255,255,255,0.25);
|
text-transform: uppercase; color: rgba(255,255,255,0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Scrollable item grid ── */
|
|
||||||
.inv-scroll {
|
.inv-scroll {
|
||||||
position: relative; z-index: 2;
|
position: relative; z-index: 2;
|
||||||
flex: 1; overflow-y: auto; scrollbar-width: none;
|
flex: 1; overflow-y: auto; scrollbar-width: none;
|
||||||
@ -162,7 +156,6 @@ const STYLES = `
|
|||||||
}
|
}
|
||||||
.inv-scroll::-webkit-scrollbar { display: none; }
|
.inv-scroll::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
/* ── Empty state ── */
|
|
||||||
.inv-empty {
|
.inv-empty {
|
||||||
display: flex; flex-direction: column; align-items: center;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
justify-content: center; gap: 0.6rem;
|
justify-content: center; gap: 0.6rem;
|
||||||
@ -173,7 +166,6 @@ const STYLES = `
|
|||||||
}
|
}
|
||||||
.inv-empty-icon { font-size: 2.5rem; opacity: 0.4; }
|
.inv-empty-icon { font-size: 2.5rem; opacity: 0.4; }
|
||||||
|
|
||||||
/* ── Loading skeleton ── */
|
|
||||||
.inv-skeleton-grid {
|
.inv-skeleton-grid {
|
||||||
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
|
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
|
||||||
}
|
}
|
||||||
@ -187,12 +179,10 @@ const STYLES = `
|
|||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Item grid ── */
|
|
||||||
.inv-grid {
|
.inv-grid {
|
||||||
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
|
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Item card ── */
|
|
||||||
.inv-card {
|
.inv-card {
|
||||||
border-radius: 20px; padding: 1rem;
|
border-radius: 20px; padding: 1rem;
|
||||||
border: 1.5px solid rgba(255,255,255,0.07);
|
border: 1.5px solid rgba(255,255,255,0.07);
|
||||||
@ -213,8 +203,6 @@ const STYLES = `
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
.inv-card:active { transform: translateY(0) scale(0.98); }
|
.inv-card:active { transform: translateY(0) scale(0.98); }
|
||||||
|
|
||||||
/* Active card styling */
|
|
||||||
.inv-card.is-active {
|
.inv-card.is-active {
|
||||||
border-color: rgba(251,191,36,0.4);
|
border-color: rgba(251,191,36,0.4);
|
||||||
background: rgba(251,191,36,0.06);
|
background: rgba(251,191,36,0.06);
|
||||||
@ -223,26 +211,17 @@ const STYLES = `
|
|||||||
border-color: rgba(251,191,36,0.6);
|
border-color: rgba(251,191,36,0.6);
|
||||||
background: rgba(251,191,36,0.09);
|
background: rgba(251,191,36,0.09);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Just-activated flash */
|
|
||||||
@keyframes invActivateFlash {
|
@keyframes invActivateFlash {
|
||||||
0% { background: rgba(251,191,36,0.25); border-color: rgba(251,191,36,0.8); }
|
0% { background: rgba(251,191,36,0.25); border-color: rgba(251,191,36,0.8); }
|
||||||
100%{ background: rgba(251,191,36,0.06); border-color: rgba(251,191,36,0.4); }
|
100%{ background: rgba(251,191,36,0.06); border-color: rgba(251,191,36,0.4); }
|
||||||
}
|
}
|
||||||
.inv-card.just-activated {
|
.inv-card.just-activated { animation: invActivateFlash 0.9s ease forwards; }
|
||||||
animation: invActivateFlash 0.9s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card shimmer overlay */
|
|
||||||
.inv-card-sheen {
|
.inv-card-sheen {
|
||||||
position: absolute; inset: 0; pointer-events: none;
|
position: absolute; inset: 0; pointer-events: none;
|
||||||
background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.04) 50%, transparent 70%);
|
background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.04) 50%, transparent 70%);
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%); transition: transform 0.5s ease;
|
||||||
transition: transform 0.5s ease;
|
|
||||||
}
|
}
|
||||||
.inv-card:hover .inv-card-sheen { transform: translateX(100%); }
|
.inv-card:hover .inv-card-sheen { transform: translateX(100%); }
|
||||||
|
|
||||||
/* Icon area */
|
|
||||||
.inv-card-icon-wrap {
|
.inv-card-icon-wrap {
|
||||||
width: 44px; height: 44px; border-radius: 14px;
|
width: 44px; height: 44px; border-radius: 14px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
@ -258,30 +237,23 @@ const STYLES = `
|
|||||||
.inv-card-active-dot {
|
.inv-card-active-dot {
|
||||||
position: absolute; top: -3px; right: -3px;
|
position: absolute; top: -3px; right: -3px;
|
||||||
width: 10px; height: 10px; border-radius: 50%;
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
background: #fbbf24;
|
background: #fbbf24; border: 2px solid #08111f;
|
||||||
border: 2px solid #08111f;
|
|
||||||
animation: invDotPulse 2s ease-in-out infinite;
|
animation: invDotPulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@keyframes invDotPulse {
|
@keyframes invDotPulse {
|
||||||
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0.6); }
|
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0.6); }
|
||||||
50% { box-shadow: 0 0 0 5px rgba(251,191,36,0); }
|
50% { box-shadow: 0 0 0 5px rgba(251,191,36,0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card text */
|
|
||||||
.inv-card-name {
|
.inv-card-name {
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: 'Nunito', sans-serif;
|
||||||
font-size: 0.82rem; font-weight: 900; color: #fff;
|
font-size: 0.82rem; font-weight: 900; color: #fff; line-height: 1.2;
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
.inv-card.is-active .inv-card-name { color: #fbbf24; }
|
.inv-card.is-active .inv-card-name { color: #fbbf24; }
|
||||||
.inv-card-desc {
|
.inv-card-desc {
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
font-size: 0.63rem; font-weight: 600;
|
font-size: 0.63rem; font-weight: 600;
|
||||||
color: rgba(255,255,255,0.38); line-height: 1.4;
|
color: rgba(255,255,255,0.38); line-height: 1.4; flex: 1;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Qty + type row */
|
|
||||||
.inv-card-meta {
|
.inv-card-meta {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
gap: 0.4rem; margin-top: auto;
|
gap: 0.4rem; margin-top: auto;
|
||||||
@ -299,11 +271,8 @@ const STYLES = `
|
|||||||
letter-spacing: 0.1em; text-transform: uppercase;
|
letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
color: rgba(255,255,255,0.22);
|
color: rgba(255,255,255,0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Activate button */
|
|
||||||
.inv-activate-btn {
|
.inv-activate-btn {
|
||||||
width: 100%;
|
width: 100%; padding: 0.48rem;
|
||||||
padding: 0.48rem;
|
|
||||||
border-radius: 10px; border: none; cursor: pointer;
|
border-radius: 10px; border: none; cursor: pointer;
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: 'Nunito', sans-serif;
|
||||||
font-size: 0.7rem; font-weight: 900;
|
font-size: 0.7rem; font-weight: 900;
|
||||||
@ -315,10 +284,7 @@ const STYLES = `
|
|||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
color: rgba(255,255,255,0.6);
|
color: rgba(255,255,255,0.6);
|
||||||
}
|
}
|
||||||
.inv-activate-btn.idle:hover {
|
.inv-activate-btn.idle:hover { background: rgba(255,255,255,0.12); color: white; }
|
||||||
background: rgba(255,255,255,0.12);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.inv-activate-btn.activating {
|
.inv-activate-btn.activating {
|
||||||
background: rgba(251,191,36,0.1);
|
background: rgba(251,191,36,0.1);
|
||||||
border: 1px solid rgba(251,191,36,0.25);
|
border: 1px solid rgba(251,191,36,0.25);
|
||||||
@ -330,8 +296,7 @@ const STYLES = `
|
|||||||
.inv-activate-btn.active-state {
|
.inv-activate-btn.active-state {
|
||||||
background: rgba(251,191,36,0.12);
|
background: rgba(251,191,36,0.12);
|
||||||
border: 1px solid rgba(251,191,36,0.3);
|
border: 1px solid rgba(251,191,36,0.3);
|
||||||
color: #fbbf24;
|
color: #fbbf24; cursor: default;
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
.inv-activate-btn.success-flash {
|
.inv-activate-btn.success-flash {
|
||||||
background: rgba(74,222,128,0.18);
|
background: rgba(74,222,128,0.18);
|
||||||
@ -339,24 +304,17 @@ const STYLES = `
|
|||||||
color: #4ade80;
|
color: #4ade80;
|
||||||
animation: invSuccessScale 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
|
animation: invSuccessScale 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
}
|
}
|
||||||
@keyframes invSuccessScale {
|
@keyframes invSuccessScale { from{transform:scale(0.94)} to{transform:scale(1)} }
|
||||||
from { transform: scale(0.94); }
|
|
||||||
to { transform: scale(1); }
|
|
||||||
}
|
|
||||||
.inv-activate-btn:disabled { pointer-events: none; }
|
.inv-activate-btn:disabled { pointer-events: none; }
|
||||||
|
|
||||||
/* Time remaining on active button */
|
|
||||||
.inv-active-time {
|
.inv-active-time {
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
font-size: 0.55rem; font-weight: 700;
|
font-size: 0.55rem; font-weight: 700; color: rgba(251,191,36,0.5);
|
||||||
color: rgba(251,191,36,0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Toast ── */
|
|
||||||
.inv-toast {
|
.inv-toast {
|
||||||
position: fixed; bottom: calc(1.5rem + env(safe-area-inset-bottom));
|
position: fixed; bottom: calc(1.5rem + env(safe-area-inset-bottom));
|
||||||
left: 50%; transform: translateX(-50%);
|
left: 50%; transform: translateX(-50%);
|
||||||
z-index: 90;
|
z-index: 9999;
|
||||||
display: flex; align-items: center; gap: 0.55rem;
|
display: flex; align-items: center; gap: 0.55rem;
|
||||||
padding: 0.7rem 1.2rem;
|
padding: 0.7rem 1.2rem;
|
||||||
background: linear-gradient(135deg, #1a3a1a, #0d2010);
|
background: linear-gradient(135deg, #1a3a1a, #0d2010);
|
||||||
@ -380,13 +338,11 @@ const ITEM_ICON: Record<string, string> = {
|
|||||||
title: "🏴☠️",
|
title: "🏴☠️",
|
||||||
coin_boost: "🪙",
|
coin_boost: "🪙",
|
||||||
};
|
};
|
||||||
const ITEM_ICON_DEFAULT = "📦";
|
|
||||||
|
|
||||||
function itemIcon(effectType: string): string {
|
function itemIcon(effectType: string): string {
|
||||||
return ITEM_ICON[effectType] ?? ITEM_ICON_DEFAULT;
|
return ITEM_ICON[effectType] ?? "📦";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Check if an item is currently active ─────────────────────────────────────
|
|
||||||
function isItemActive(
|
function isItemActive(
|
||||||
item: InventoryItem,
|
item: InventoryItem,
|
||||||
activeEffects: ActiveEffect[],
|
activeEffects: ActiveEffect[],
|
||||||
@ -438,33 +394,23 @@ const ItemCard = ({
|
|||||||
style={{ "--ci-delay": `${index * 0.045}s` } as React.CSSProperties}
|
style={{ "--ci-delay": `${index * 0.045}s` } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<div className="inv-card-sheen" />
|
<div className="inv-card-sheen" />
|
||||||
|
|
||||||
{/* Icon */}
|
|
||||||
<div className="inv-card-icon-wrap">
|
<div className="inv-card-icon-wrap">
|
||||||
{itemIcon(inv.item.effect_type)}
|
{itemIcon(inv.item.effect_type)}
|
||||||
{isActive && <div className="inv-card-active-dot" />}
|
{isActive && <div className="inv-card-active-dot" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name + description */}
|
|
||||||
<p className="inv-card-name">{inv.item.name}</p>
|
<p className="inv-card-name">{inv.item.name}</p>
|
||||||
<p className="inv-card-desc">{inv.item.description}</p>
|
<p className="inv-card-desc">{inv.item.description}</p>
|
||||||
|
|
||||||
{/* Qty + type */}
|
|
||||||
<div className="inv-card-meta">
|
<div className="inv-card-meta">
|
||||||
<span className="inv-card-qty">×{inv.quantity}</span>
|
<span className="inv-card-qty">×{inv.quantity}</span>
|
||||||
<span className="inv-card-type">
|
<span className="inv-card-type">
|
||||||
{inv.item.type.replace(/_/g, " ")}
|
{inv.item.type.replace(/_/g, " ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time remaining if active */}
|
|
||||||
{isActive && activeEffect && (
|
{isActive && activeEffect && (
|
||||||
<div className="inv-active-time">
|
<div className="inv-active-time">
|
||||||
{formatTimeLeft(activeEffect.expires_at)} remaining
|
{formatTimeLeft(activeEffect.expires_at)} remaining
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Activate button */}
|
|
||||||
<button
|
<button
|
||||||
className={`inv-activate-btn ${btnState}`}
|
className={`inv-activate-btn ${btnState}`}
|
||||||
onClick={() => !isActive && !isActivating && onActivate(inv.id)}
|
onClick={() => !isActive && !isActivating && onActivate(inv.id)}
|
||||||
@ -504,11 +450,9 @@ export const InventoryModal = ({ onClose }: Props) => {
|
|||||||
const [toastMsg, setToastMsg] = useState("");
|
const [toastMsg, setToastMsg] = useState("");
|
||||||
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// ── Fetch on open ──────────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const fetchInv = async () => {
|
const fetchInv = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -520,31 +464,24 @@ export const InventoryModal = ({ onClose }: Props) => {
|
|||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchInv();
|
fetchInv();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
// ── Activate ──────────────────────────────────────────────────────────────
|
|
||||||
const handleActivate = useCallback(
|
const handleActivate = useCallback(
|
||||||
async (itemId: string) => {
|
async (itemId: string) => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
activateItemOptimistic(itemId);
|
activateItemOptimistic(itemId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedInv = await api.activateItem(token, itemId);
|
const updatedInv = await api.activateItem(token, itemId);
|
||||||
activateItemSuccess(updatedInv, itemId);
|
activateItemSuccess(updatedInv, itemId);
|
||||||
|
|
||||||
// Find item name for toast
|
|
||||||
const name = items.find((i) => i.id === itemId)?.item.name ?? "Item";
|
const name = items.find((i) => i.id === itemId)?.item.name ?? "Item";
|
||||||
setToastMsg(
|
setToastMsg(
|
||||||
`${itemIcon(items.find((i) => i.id === itemId)?.item.effect_type ?? "")} ${name} activated!`,
|
`${itemIcon(items.find((i) => i.id === itemId)?.item.effect_type ?? "")} ${name} activated!`,
|
||||||
);
|
);
|
||||||
setShowToast(true);
|
setShowToast(true);
|
||||||
|
|
||||||
// Auto-clear success state + toast
|
|
||||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||||
toastTimer.current = setTimeout(() => {
|
toastTimer.current = setTimeout(() => {
|
||||||
setShowToast(false);
|
setShowToast(false);
|
||||||
@ -560,7 +497,6 @@ export const InventoryModal = ({ onClose }: Props) => {
|
|||||||
[token, items],
|
[token, items],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cleanup timer on unmount
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||||
@ -570,18 +506,19 @@ export const InventoryModal = ({ onClose }: Props) => {
|
|||||||
|
|
||||||
const liveEffects = getLiveEffects(activeEffects);
|
const liveEffects = getLiveEffects(activeEffects);
|
||||||
|
|
||||||
return (
|
// Portal the entire modal to document.body so it always
|
||||||
|
// renders at the top of the DOM tree, escaping any parent
|
||||||
|
// stacking context, overflow:hidden, or z-index constraints.
|
||||||
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<style>{STYLES}</style>
|
<style>{STYLES}</style>
|
||||||
|
|
||||||
<div className="inv-overlay" onClick={onClose}>
|
<div className="inv-overlay" onClick={onClose}>
|
||||||
<div className="inv-sheet" onClick={(e) => e.stopPropagation()}>
|
<div className="inv-sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Handle */}
|
|
||||||
<div className="inv-handle-row">
|
<div className="inv-handle-row">
|
||||||
<div className="inv-handle" />
|
<div className="inv-handle" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="inv-header">
|
<div className="inv-header">
|
||||||
<div className="inv-header-left">
|
<div className="inv-header-left">
|
||||||
<span className="inv-eyebrow">⚓ Pirate's Hold</span>
|
<span className="inv-eyebrow">⚓ Pirate's Hold</span>
|
||||||
@ -592,7 +529,6 @@ export const InventoryModal = ({ onClose }: Props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active effects bar */}
|
|
||||||
{liveEffects.length > 0 && (
|
{liveEffects.length > 0 && (
|
||||||
<div className="inv-active-bar">
|
<div className="inv-active-bar">
|
||||||
{liveEffects.map((e) => (
|
{liveEffects.map((e) => (
|
||||||
@ -616,7 +552,6 @@ export const InventoryModal = ({ onClose }: Props) => {
|
|||||||
: "Your hold"}
|
: "Your hold"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Scroll area */}
|
|
||||||
<div className="inv-scroll">
|
<div className="inv-scroll">
|
||||||
{loading && items.length === 0 ? (
|
{loading && items.length === 0 ? (
|
||||||
<div className="inv-skeleton-grid">
|
<div className="inv-skeleton-grid">
|
||||||
@ -649,7 +584,6 @@ export const InventoryModal = ({ onClose }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error inline */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
@ -668,8 +602,8 @@ export const InventoryModal = ({ onClose }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success toast */}
|
|
||||||
{showToast && <div className="inv-toast">{toastMsg}</div>}
|
{showToast && <div className="inv-toast">{toastMsg}</div>}
|
||||||
</>
|
</>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { QuizData } from '../types';
|
import { type QuizData } from "../../types/lesson";
|
||||||
import { CheckCircle2, XCircle, ChevronRight } from 'lucide-react';
|
import { CheckCircle2, XCircle, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
interface QuizProps {
|
interface QuizProps {
|
||||||
data: QuizData;
|
data: QuizData;
|
||||||
@ -21,20 +21,24 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
|||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
const selectedOption = data.options.find(opt => opt.id === selectedId);
|
const selectedOption = data.options.find((opt) => opt.id === selectedId);
|
||||||
if (selectedOption?.isCorrect && onComplete) {
|
if (selectedOption?.isCorrect && onComplete) {
|
||||||
onComplete();
|
onComplete();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedOption = data.options.find(opt => opt.id === selectedId);
|
const selectedOption = data.options.find((opt) => opt.id === selectedId);
|
||||||
const isCorrect = selectedOption?.isCorrect;
|
const isCorrect = selectedOption?.isCorrect;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-2xl mx-auto bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden mt-6">
|
<div className="w-full max-w-2xl mx-auto bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden mt-6">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h4 className="text-sm font-bold text-slate-400 uppercase tracking-wider mb-2">Concept Check</h4>
|
<h4 className="text-sm font-bold text-slate-400 uppercase tracking-wider mb-2">
|
||||||
<p className="text-lg font-medium text-slate-900 mb-6 whitespace-pre-line">{data.question}</p>
|
Concept Check
|
||||||
|
</h4>
|
||||||
|
<p className="text-lg font-medium text-slate-900 mb-6 whitespace-pre-line">
|
||||||
|
{data.question}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{data.options.map((option) => {
|
{data.options.map((option) => {
|
||||||
@ -67,14 +71,22 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
|||||||
className={`w-full text-left p-4 rounded-lg border-2 transition-all duration-200 flex items-center justify-between group ${borderClass} ${bgClass}`}
|
className={`w-full text-left p-4 rounded-lg border-2 transition-all duration-200 flex items-center justify-between group ${borderClass} ${bgClass}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className={`w-6 h-6 flex items-center justify-center rounded-full text-xs font-bold mr-3 ${
|
<span
|
||||||
isSubmitted && option.isCorrect ? 'bg-green-200 text-green-800' :
|
className={`w-6 h-6 flex items-center justify-center rounded-full text-xs font-bold mr-3 ${
|
||||||
isSubmitted && option.id === selectedId ? 'bg-red-200 text-red-800' :
|
isSubmitted && option.isCorrect
|
||||||
selectedId === option.id ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-500'
|
? "bg-green-200 text-green-800"
|
||||||
}`}>
|
: isSubmitted && option.id === selectedId
|
||||||
|
? "bg-red-200 text-red-800"
|
||||||
|
: selectedId === option.id
|
||||||
|
? "bg-indigo-600 text-white"
|
||||||
|
: "bg-slate-100 text-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{option.id}
|
{option.id}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-700 group-hover:text-slate-900">{option.text}</span>
|
<span className="text-slate-700 group-hover:text-slate-900">
|
||||||
|
{option.text}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{icon}
|
{icon}
|
||||||
</button>
|
</button>
|
||||||
@ -85,13 +97,25 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
|||||||
|
|
||||||
{/* Feedback Section */}
|
{/* Feedback Section */}
|
||||||
{isSubmitted && (
|
{isSubmitted && (
|
||||||
<div className={`p-6 border-t ${isCorrect ? 'bg-green-50 border-green-100' : 'bg-slate-50 border-slate-100'}`}>
|
<div
|
||||||
|
className={`p-6 border-t ${isCorrect ? "bg-green-50 border-green-100" : "bg-slate-50 border-slate-100"}`}
|
||||||
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className={`mt-1 p-1 rounded-full ${isCorrect ? 'bg-green-200' : 'bg-slate-200'}`}>
|
<div
|
||||||
{isCorrect ? <CheckCircle2 className="w-4 h-4 text-green-700" /> : <div className="w-4 h-4 text-slate-500 font-bold text-center leading-4">i</div>}
|
className={`mt-1 p-1 rounded-full ${isCorrect ? "bg-green-200" : "bg-slate-200"}`}
|
||||||
|
>
|
||||||
|
{isCorrect ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-700" />
|
||||||
|
) : (
|
||||||
|
<div className="w-4 h-4 text-slate-500 font-bold text-center leading-4">
|
||||||
|
i
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className={`font-bold ${isCorrect ? 'text-green-800' : 'text-slate-800'} mb-1`}>
|
<p
|
||||||
|
className={`font-bold ${isCorrect ? "text-green-800" : "text-slate-800"} mb-1`}
|
||||||
|
>
|
||||||
{isCorrect ? "That's right!" : "Not quite."}
|
{isCorrect ? "That's right!" : "Not quite."}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-slate-600 mb-2">{selectedOption?.feedback}</p>
|
<p className="text-slate-600 mb-2">{selectedOption?.feedback}</p>
|
||||||
@ -111,8 +135,8 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
|||||||
disabled={!selectedId}
|
disabled={!selectedId}
|
||||||
className={`px-6 py-2 rounded-full font-semibold transition-all flex items-center ${
|
className={`px-6 py-2 rounded-full font-semibold transition-all flex items-center ${
|
||||||
selectedId
|
selectedId
|
||||||
? 'bg-slate-900 text-white hover:bg-slate-800 shadow-md transform hover:-translate-y-0.5'
|
? "bg-slate-900 text-white hover:bg-slate-800 shadow-md transform hover:-translate-y-0.5"
|
||||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
: "bg-slate-200 text-slate-400 cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Check Answer <ChevronRight className="w-4 h-4 ml-1" />
|
Check Answer <ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
|||||||
Reference in New Issue
Block a user