Skip to content
Merged
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
80 changes: 77 additions & 3 deletions client/src/components/AdminPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import {
ArrowDownward,
Close,
Search as SearchIcon,
Pause,
PlayArrow,
ExpandMore
} from '@mui/icons-material';
import AppearanceSettings from './AppearanceSettings';
Expand Down Expand Up @@ -260,6 +262,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
const [githubWidgets, setGithubWidgets] = useState([]);
// Quick search over the plugin store, by name.
const [pluginSearch, setPluginSearch] = useState('');
const [prizeSearch, setPrizeSearch] = useState('');
// Store categories start collapsed; these are the ones opened by hand.
const [expandedPluginCategories, setExpandedPluginCategories] = useState(() => new Set());
const togglePluginCategory = (category) => {
Expand Down Expand Up @@ -287,6 +290,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
// display that remembers it; the server checks it again. 'change' | 'remove'.
const [currentPinFor, setCurrentPinFor] = useState(null);
const currentPin = useRef('');
const [pinRememberedHere, setPinRememberedHere] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [checkingPin, setCheckingPin] = useState(true);
const { tabs, fetchTabs } = useFetchTabs(API_DEVICE_URL);
Expand Down Expand Up @@ -440,6 +444,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
// here as well as in the chore widget. Honouring it in one gate and not
// the other would read as broken.
const remembered = exists ? await fetchPinRemembered(API_BASE_URL) : false;
setPinRememberedHere(remembered);

if (shouldPromptForPin({ pinExists: exists, remembered })) {
setPinModal({ open: true, mode: 'verify', title: t('admin:pin.enter') });
Expand Down Expand Up @@ -526,6 +531,8 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
}
};

const prizeMatchesSearch = (prize) => `${prize.name} ${prize.description || ''}`.toLowerCase().includes(prizeSearch.trim().toLowerCase());

const fetchPrizes = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/api/prizes`);
Expand Down Expand Up @@ -1560,6 +1567,17 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
}
};

const togglePrizePaused = async (prize) => {
try {
await axios.patch(`${API_BASE_URL}/api/prizes/${prize.id}`, { paused: !prize.paused });
await fetchPrizes();
} catch (error) {
console.error('Error pausing prize:', error);
setSaveMessage({ show: true, type: 'error', text: t('admin:messages.prizePauseFailed') });
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
}
};

const deletePrize = async (prizeId) => {
if (window.confirm(t('admin:confirm.deletePrize'))) {
try {
Expand Down Expand Up @@ -1799,6 +1817,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
try {
const { failed } = await forgetPinOnAllDevices(API_BASE_URL);
forgetFailed = failed > 0;
setPinRememberedHere(await fetchPinRemembered(API_BASE_URL));
} catch (error) {
console.error('Error clearing remembered PIN devices after a PIN change:', error);
forgetFailed = true;
Expand All @@ -1819,6 +1838,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
// a failure to record the preference.
try {
await setPinRemembered(API_BASE_URL, true);
setPinRememberedHere(true);
} catch (error) {
console.error('Error remembering PIN on this device:', error);
}
Expand Down Expand Up @@ -1847,10 +1867,25 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
setPinModal({ open: false, mode: 'verify', title: '' });
};

// Takes effect the next time the PIN would be asked for; this open panel stays open.
const handleForgetPinHere = async () => {
try {
await setPinRemembered(API_BASE_URL, false);
setPinRememberedHere(false);
window.dispatchEvent(new Event(DEVICE_SETTINGS_UPDATED_EVENT));
setSaveMessage({ show: true, type: 'success', text: t('admin:messages.pinForgottenHere') });
} catch (error) {
console.error('Error forgetting the PIN on this device:', error);
setSaveMessage({ show: true, type: 'error', text: t('admin:messages.pinDevicesForgetFailed') });
}
setTimeout(() => setSaveMessage({ show: false, type: '', text: '' }), 3000);
};

const handleForgetPinDevices = async () => {
if (!window.confirm(t('admin:confirm.forgetPinDevices'))) return;
try {
const { total, failed } = await forgetPinOnAllDevices(API_BASE_URL);
setPinRememberedHere(await fetchPinRemembered(API_BASE_URL));
if (failed > 0) {
setSaveMessage({
show: true,
Expand Down Expand Up @@ -3405,7 +3440,34 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
</AdminGroup>

<AdminGroup title={t('admin:prizes.management')}>
{prizes.map((prize) => (editingPrize?.id === prize.id ? (
<Box sx={{ px: 2, py: 1.5 }}>
<TextField
fullWidth
size="small"
value={prizeSearch}
onChange={(event) => setPrizeSearch(event.target.value)}
placeholder={t('admin:prizes.search')}
slotProps={{
htmlInput: { 'aria-label': t('admin:prizes.search') },
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: prizeSearch && (
<InputAdornment position="end">
<IconButton size="small" edge="end" aria-label={t('common:actions.clear')} onClick={() => setPrizeSearch('')}>
<Close fontSize="small" />
</IconButton>
</InputAdornment>
),
},
}}
/>
</Box>
{prizeSearch.trim() && !prizes.some(prizeMatchesSearch) && <AdminNote>{t('admin:prizes.noMatches')}</AdminNote>}
{prizes.filter((prize) => editingPrize?.id === prize.id || prizeMatchesSearch(prize)).map((prize) => (editingPrize?.id === prize.id ? (
<Box key={prize.id} sx={{ px: 2, py: 1.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
Expand Down Expand Up @@ -3471,6 +3533,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
{prize.repeatable
? t('admin:prizes.costLineRepeatable', { cost: prize.clam_cost })
: t('admin:prizes.costLine', { cost: prize.clam_cost })}
{prize.paused ? ` · ${t('admin:prizes.pausedShort')}` : ''}
</>
)}
control={(
Expand All @@ -3480,6 +3543,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
<Add fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={prize.paused ? t('admin:prizes.resume') : t('admin:prizes.pause')}>
<IconButton onClick={() => togglePrizePaused(prize)} size="small" aria-label={`${prize.paused ? t('admin:prizes.resume') : t('admin:prizes.pause')}: ${prize.name}`}>
{prize.paused ? <PlayArrow fontSize="small" /> : <Pause fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title={t('common:actions.edit')}>
<IconButton onClick={() => setEditingPrize({ ...prize })} size="small" aria-label={t('common:actions.edit')}>
<Edit fontSize="small" />
Expand All @@ -3505,8 +3573,8 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
key={offer.id}
label={`${offer.name} — ${offer.clam_cost} 🥟${offer.repeatable ? ' · 🔁' : ''}`}
hint={offer.status === 'requested'
? t('admin:prizes.requestedBy', { name: offer.requested_by_name || t('common:state.none') })
: 'On the shelf'}
? `${t('admin:prizes.requestedBy', { name: offer.requested_by_name || t('common:state.none') })}${offer.paused ? ` · ${t('admin:prizes.pausedShort')}` : ''}`
: (offer.paused ? t('admin:prizes.pausedHidden') : t('admin:prizes.onTheShelf'))}
control={(
<Tooltip title={offer.status === 'requested' ? 'Decline request and remove from store' : 'Remove from store'}>
<IconButton onClick={() => removePrizeOffer(offer.id)} color="error" size="small" aria-label={offer.status === 'requested' ? 'Decline request and remove from store' : 'Remove from store'}>
Expand Down Expand Up @@ -3626,6 +3694,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
<AdminRow
control={(
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', width: '100%', justifyContent: 'flex-end' }}>
{pinRememberedHere && (
<Button variant="outlined" onClick={handleForgetPinHere}>
{t('admin:pin.forgetHere')}
</Button>
)}
<Button variant="outlined" onClick={handleForgetPinDevices}>
{t('admin:pin.forgetDevices')}
</Button>
Expand All @@ -3640,6 +3713,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
</AdminGroup>

<ControlsOnDisplay
key={String(pinRememberedHere)}
apiBaseUrl={API_BASE_URL}
devices={devices}
currentDeviceName={currentDeviceName}
Expand Down
8 changes: 4 additions & 4 deletions client/src/components/ChoreWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => {
setSplitDraft({ offerId: null, userIds: [] });
await fetchPrizeOffers();
} catch (error) {
alert(error?.response?.data?.error || 'Could not request this prize.');
alert(error?.response?.data?.code === 'PRIZE_PAUSED' ? t('chores:prizeStore.paused') : (error?.response?.data?.error || 'Could not request this prize.'));
await fetchPrizeOffers();
}
};
Expand Down Expand Up @@ -1686,7 +1686,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => {
</Typography>
</DialogTitle>
<DialogContent>
{prizeOffers.length === 0 ? (
{prizeOffers.filter((offer) => offer.status === 'requested' || !offer.paused).length === 0 ? (
<Typography variant="body1" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
{t('chores:prizeStore.empty')}
</Typography>
Expand Down Expand Up @@ -1752,12 +1752,12 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => {
</Box>
))}

{prizeOffers.filter((offer) => offer.status === 'available').length > 0 && (
{prizeOffers.filter((offer) => offer.status === 'available' && !offer.paused).length > 0 && (
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', opacity: 0.7 }}>
{t('chores:prizeStore.onTheShelf')}
</Typography>
)}
{prizeOffers.filter((offer) => offer.status === 'available').map((offer) => (
{prizeOffers.filter((offer) => offer.status === 'available' && !offer.paused).map((offer) => (
<Box
key={offer.id}
sx={{
Expand Down
9 changes: 9 additions & 0 deletions client/src/i18n/locales/en/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"userDeleteFailed": "Failed to delete user. Please try again.",
"prizeAddToStoreFailed": "Failed to add the prize to the store.",
"prizeRemoveOfferFailed": "Failed to remove the offer.",
"prizePauseFailed": "Could not change that prize. Try again.",
"widgetUploadFailed": "Failed to upload widget. Please try again.",
"widgetInstalled": "Widget \"{{name}}\" installed successfully!",
"widgetInstallFailed": "Failed to install widget. Please try again.",
Expand All @@ -110,6 +111,7 @@
"pinRemoveFailed": "Failed to remove PIN. Please try again.",
"pinDevicesForgotten": "The PIN is required again on all {{total}} devices.",
"pinDevicesPartlyForgotten": "Could not update {{failed}} of {{total}} devices. Try again.",
"pinForgottenHere": "This device will ask for the PIN next time.",
"pinDevicesForgetFailed": "Could not update devices. Try again.",
"pinChangedDevicesForgotten": "PIN updated. Every device will be asked for it again.",
"pinChangedDevicesNotForgotten": "PIN updated, but some devices may still skip it. Use “Require PIN on all devices again”.",
Expand All @@ -127,6 +129,7 @@
"enter": "Enter Admin PIN",
"update": "Update Admin PIN",
"setPin": "Set Admin PIN",
"forgetHere": "Forget PIN on this device",
"forgetDevices": "Require PIN on all devices again",
"currentForChange": "Enter the current PIN",
"currentForRemove": "Enter the PIN to remove it",
Expand Down Expand Up @@ -454,6 +457,12 @@
"storeEmpty": "The store is empty. Use ➕ next to a prize above to stock it.",
"requestedBy": "Requested by {{name}} — approve or decline on the dashboard",
"onTheShelf": "On the shelf",
"search": "Search prizes",
"noMatches": "No prizes match.",
"pause": "Pause: hide from the store",
"resume": "Resume",
"pausedShort": "⏸ Paused",
"pausedHidden": "⏸ Paused, hidden from the store",
"addToStore": "Add to store (one-time redeemable offer)",
"addToStoreRepeatable": "Add to store (stays on the shelf after each redemption)",
"removeFromStore": "Remove from store",
Expand Down
1 change: 1 addition & 0 deletions client/src/i18n/locales/en/chores.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"invalidDate": "Enter a valid date and time to snooze until."
},
"prizeStore": {
"paused": "That prize is paused right now.",
"title": "🛍️ Prize Store",
"empty": "The store is empty right now. Parents can stock it from Settings → Prize Management.",
"waitingForParent": "Waiting for a parent",
Expand Down
9 changes: 9 additions & 0 deletions client/src/i18n/locales/es/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"userDeleteFailed": "No se pudo eliminar a la persona. Inténtalo de nuevo.",
"prizeAddToStoreFailed": "No se pudo añadir el premio a la tienda.",
"prizeRemoveOfferFailed": "No se pudo quitar la oferta.",
"prizePauseFailed": "No se pudo cambiar ese premio. Inténtalo de nuevo.",
"widgetUploadFailed": "No se pudo subir el widget. Inténtalo de nuevo.",
"widgetInstalled": "¡El widget \"{{name}}\" se instaló correctamente!",
"widgetInstallFailed": "No se pudo instalar el widget. Inténtalo de nuevo.",
Expand All @@ -110,6 +111,7 @@
"pinRemoveFailed": "No se pudo eliminar el PIN. Inténtalo de nuevo.",
"pinDevicesForgotten": "El PIN vuelve a ser obligatorio en los {{total}} dispositivos.",
"pinDevicesPartlyForgotten": "No se pudieron actualizar {{failed}} de {{total}} dispositivos. Inténtalo de nuevo.",
"pinForgottenHere": "Este dispositivo pedirá el PIN la próxima vez.",
"pinDevicesForgetFailed": "No se pudieron actualizar los dispositivos. Inténtalo de nuevo.",
"pinChangedDevicesForgotten": "PIN actualizado. Se volverá a pedir en todos los dispositivos.",
"pinChangedDevicesNotForgotten": "PIN actualizado, pero puede que algunos dispositivos sigan omitiéndolo. Usa «Volver a exigir el PIN en todos los dispositivos».",
Expand All @@ -127,6 +129,7 @@
"enter": "Introduce el PIN de administrador",
"update": "Cambiar el PIN de administrador",
"setPin": "Configurar el PIN de administrador",
"forgetHere": "Olvidar el PIN en este dispositivo",
"forgetDevices": "Volver a exigir el PIN en todos los dispositivos",
"currentForChange": "Introduce el PIN actual",
"currentForRemove": "Introduce el PIN para eliminarlo",
Expand Down Expand Up @@ -454,6 +457,12 @@
"storeEmpty": "La tienda está vacía. Usa ➕ junto a un premio de arriba para ponerlo a la venta.",
"requestedBy": "Pedido por {{name}} — apruébalo o recházalo desde el panel",
"onTheShelf": "En la estantería",
"search": "Buscar premios",
"noMatches": "Ningún premio coincide.",
"pause": "Pausar: ocultar de la tienda",
"resume": "Reanudar",
"pausedShort": "⏸ En pausa",
"pausedHidden": "⏸ En pausa, oculto en la tienda",
"addToStore": "Añadir a la tienda (oferta de un solo canje)",
"addToStoreRepeatable": "Añadir a la tienda (se queda tras cada canje)",
"removeFromStore": "Quitar de la tienda",
Expand Down
1 change: 1 addition & 0 deletions client/src/i18n/locales/es/chores.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"invalidDate": "Introduce una fecha y hora válidas para posponer."
},
"prizeStore": {
"paused": "Ese premio está en pausa ahora mismo.",
"title": "🛍️ Tienda de premios",
"empty": "La tienda está vacía. Los padres pueden llenarla desde Ajustes → Gestión de premios.",
"waitingForParent": "Esperando a un adulto",
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,11 @@ id, username, email, profile_picture, sort_order

**`prizes`** — the prize definitions ledger (kept forever in Prize Management).
`repeatable` prizes stay on the store shelf after each redemption instead of
being consumed.
being consumed. A `paused` prize is hidden from kids and can't be requested
until it's resumed; a request already made still shows, for its kid and for a
parent to approve or decline.
```
id, name, clam_cost, repeatable (0|1)
id, name, clam_cost, repeatable (0|1), description, paused (0|1)
```

**`prize_offers`** — the prize store: redeemable instances of ledger
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ The chore system uses a **three-table model** (see [Database](../architecture/da
Approval deducts the cost as a named `spent` ledger row, consumes the offer
(the definition stays in management), and fires a **full-screen confetti
celebration + chime** on every display via the `prize.redeemed` event.
- **Pausing a prize** (⏸ on its row) hides it from the kids' store and refuses
new requests, without taking it off the shelf. A request already made still
waits for a parent. The search on Prize Management filters the prize list; the
store list below it always shows everything.
- **Repeatable prizes** (a toggle on the definition, shown as 🔁): approval
returns the offer to the shelf instead of consuming it, for prizes like
"movie night" that can be redeemed again and again.
Expand Down
Loading
Loading