Update Todos
Skriv migrasjon 033 (e-post-preferanser per varseltype) Legg til send_notification_email() i app/email.py Bygg GET/PUT /notifications/email-prefs + koble e-post inn i create_notification() Bygg frontend: velg varseltyper for e-post i /account Scratch-verifiser hele løpet (preferanser + faktisk e-postutsending) Oppdater CLAUDE.md + rull ut mot ekte systemer (med bekreftelse) Live. E-post-fallback for varsler er ferdig, per-type valgbar av mottakeren selv: Ny seksjon "Varsler på e-post" i /account — fire avkrysningsbokser (Venneforespørsler/Runder/Resultater/Turneringer), trygg standard er ingen valgt (ingen e-post inntil du eksplisitt krysser av). E-post-utsendingen ligger ett sted (inni create_notification()), så alle nåværende og fremtidige varseltyper får e-post-støtte automatisk. 19/19 nye sjekker + full regresjon av fire eksisterende testsuiter (219 sjekker totalt) grønt. Migrasjon 033 kjørt mot ekte database (kun ny tabell), test_isolation.sql fortsatt 12/12, begge containere redeployet, teeoff.no upåvirket.
This commit is contained in:
parent
4c0fd666a4
commit
58a1242a9b
6 changed files with 316 additions and 14 deletions
21
033_notification_email_prefs.sql
Normal file
21
033_notification_email_prefs.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — e-post-fallback for varsler, per type (migrasjon 033,
|
||||
-- 2026-07-28 oppfølging av varslingssenteret)
|
||||
-- =====================================================================
|
||||
-- Trygg standard: en bruker som ikke har gjort noe valg får ALDRI e-post
|
||||
-- (samme "se ingenting"-filosofi som resten av appen, ADR-018/036) --
|
||||
-- raden i user_notification_email_pref må EKSPLISITT finnes for at
|
||||
-- create_notification() (app/routers/notifications.py) skal sende e-post
|
||||
-- for akkurat den typen. Samme "tilstedeværelse = valgt"-mønster som
|
||||
-- round_visible_category (032) og friend_categorization (025) -- ingen
|
||||
-- egen boolean-kolonne per type, en rad per (bruker, type) i stedet.
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
CREATE TABLE user_notification_email_pref (
|
||||
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
||||
-- Samme faste sett som notification.type (026_notifications.sql).
|
||||
type text NOT NULL CHECK (type IN ('friend', 'tournament', 'round', 'result')),
|
||||
PRIMARY KEY (user_id, type)
|
||||
);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON user_notification_email_pref TO teecup_app;
|
||||
59
CLAUDE.md
59
CLAUDE.md
|
|
@ -4667,6 +4667,65 @@ Ferdig og verifisert:
|
|||
(`Application startup complete`), `/health`/`/dashboard` → 200,
|
||||
`teeoff.no` upåvirket.
|
||||
|
||||
- **E-post-fallback for varsler, per type, BYGGET OG SCRATCH-VERIFISERT
|
||||
(2026-07-28), samme dag, rett etter rundevarsel-runden:** brukeren ba
|
||||
eksplisitt om at mottakeren selv skal kunne velge HVILKE varseltyper som
|
||||
skal utløse e-post — ikke en enkelt global av/på-bryter. Ny migrasjon
|
||||
`033_notification_email_prefs.sql`: `user_notification_email_pref`
|
||||
(`user_id`, `type`, samme CHECK-sett som `notification.type`) — samme
|
||||
"tilstedeværelse = valgt"-mønster som `round_visible_category`/
|
||||
`friend_categorization`, TRYGG STANDARD ingen rad = ingen e-post for
|
||||
noen type (samme "se ingenting til noen har valgt"-filosofi som resten
|
||||
av appen).
|
||||
**Bevisst ÉN felles innsnevring, ikke ett kallsted per varseltrigger:**
|
||||
e-post-utsendingen ligger INNI `create_notification()` selv (etter selve
|
||||
INSERT-en) — modul-docstringen kalte den allerede "den eneste
|
||||
skrivevegen inn", så alle nåværende OG fremtidige varsel-triggere (i dag
|
||||
2 i friends.py, 4 i rounds.py) får e-post-støtte helt uten å røres,
|
||||
ingen risiko for at et fremtidig kallsted glemmer det. Samme
|
||||
`SMTP_CONFIGURED`-sjekk + try/except + `traceback.print_exc()`-mønster
|
||||
som all annen e-postutsending i appen (routers/auth.py,
|
||||
organizations.py) — en driftsfeil i selve SMTP-en skal ALDRI hindre at
|
||||
in-app-varselet (allerede skrevet FØR e-post-forsøket) består.
|
||||
Ny `send_notification_email()` i `app/email.py` — bevisst tospråklig KUN
|
||||
i ramme-teksten (emne/hilsen/lenkeforklaring); selve `message`-teksten
|
||||
er allerede en ferdig norsk snapshot-tekst (samme prinsipp som
|
||||
in-app-varselet), ingen full i18n av selve varselinnholdet i denne
|
||||
runden.
|
||||
Nye `GET`/`PUT /notifications/email-prefs` — PUT er en FULL erstatning
|
||||
(samme kontrakt som `PUT /friends/{id}/categories`), ikke en delvis
|
||||
PATCH.
|
||||
**Liten, men reell ryddejobb funnet FØR e-post-laget ble lagt på:**
|
||||
forrige rundes `add_participant`-varsel (medspiller lagt til) lå INNI
|
||||
den åpne DB-transaksjonen for selve deltaker-innsettingen — flyttet til
|
||||
RETT ETTER (samme mønster som `create_round`/`complete_round` allerede
|
||||
fulgte), slik at en fremtidig blokkerende SMTP-utsending aldri skjer
|
||||
mens en transaksjon holder låser.
|
||||
**Frontend:** ny seksjon "Varsler på e-post" i `/account`
|
||||
(`NotificationEmailPrefsSection`, `account-settings.tsx`) — fire
|
||||
avkrysningsbokser (Venneforespørsler/Runder/Resultater/Turneringer, sistnevnte
|
||||
reservert for fremtidig bruk), samme avkrysningsboks-mønster som
|
||||
kølle-bag-listen lenger opp i samme fil, lagrer umiddelbart ved hvert
|
||||
klikk (ingen egen "lagre"-knapp).
|
||||
**Scratch-verifisert grundig, 19/19 nye sjekker** (isolert
|
||||
`teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API-
|
||||
container, alle 33 migrasjoner kjørt friskt): trygg standard bekreftet
|
||||
(ingen typer valgt fra start), full-erstatning bekreftet (PUT med ett
|
||||
sett fjerner det forrige, legger ikke til), e-post-fallback FAKTISK
|
||||
logget (dev-log-varianten av `SMTP_CONFIGURED`-grenen) for en bruker med
|
||||
`round`/`result` valgt inn — BÅDE ved medspiller-tilføyelse og ved
|
||||
fullføring — INGEN e-post til brukeren som selv utførte handlingen,
|
||||
INGEN e-post til en bruker (eieren) som ikke har valgt inn NOE, og etter
|
||||
å ha slått AV `result` igjen: ingen ny e-post ved neste fullføring MENS
|
||||
in-app-varselet fortsatt opprettes helt uendret (de to er reelt
|
||||
atskilte, ikke koblet). PLUSS full regresjon av fire eksisterende
|
||||
testsuiter (rundevarsler 28/28, co-player-flyt 35/35, spillformat-flyt
|
||||
122/122, ADR-036 fase 2-synlighetsmatrise 34/34) — alle fortsatt 100 %
|
||||
grønne, ingen bivirkning av at `add_participant`s varsel flyttet utenfor
|
||||
transaksjonen. `test_isolation.sql` 12/12 uendret. Ekte typesjekket
|
||||
produksjonsbuild (samme `Dockerfile` som deployes) kompilerte rent,
|
||||
alle 24 ruter listet uendret.
|
||||
|
||||
Neste steg:
|
||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
|
||||
|
|
|
|||
41
app/email.py
41
app/email.py
|
|
@ -222,3 +222,44 @@ async def send_secondary_email_verification(to_email: str, raw_token: str, local
|
|||
link = f"{settings.PUBLIC_BASE_URL}/verify-email?token={raw_token}&kind=secondary"
|
||||
body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link)
|
||||
await to_thread(_send_sync, to_email, template["subject"], body)
|
||||
|
||||
|
||||
_NOTIFICATION_TEMPLATES = {
|
||||
"nb": {
|
||||
"subject": "Nytt varsel fra TeeCup",
|
||||
"body": (
|
||||
"Hei,\n\n"
|
||||
"{message}\n\n"
|
||||
"Se det i TeeCup:\n{link}\n\n"
|
||||
"Du får denne e-posten fordi du har valgt å motta e-post for "
|
||||
"denne typen varsler. Du kan endre dette når som helst under "
|
||||
"Konto i TeeCup.\n"
|
||||
),
|
||||
},
|
||||
"en": {
|
||||
"subject": "New notification from TeeCup",
|
||||
"body": (
|
||||
"Hi,\n\n"
|
||||
"{message}\n\n"
|
||||
"View it in TeeCup:\n{link}\n\n"
|
||||
"You're receiving this because you opted in to email for this "
|
||||
"notification type. You can change this anytime under Account "
|
||||
"in TeeCup.\n"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def send_notification_email(to_email: str, message: str, link_path: str, locale: str = "nb") -> None:
|
||||
"""E-post-fallback for det in-app varslingssenteret (FEATURE_BACKLOG.md,
|
||||
2026-07-28 oppfølging) -- kun sendt når mottakeren selv har valgt inn
|
||||
for akkurat DENNE varseltypen (`user_notification_email_pref`,
|
||||
migrasjon 033), se `create_notification()` i
|
||||
`app/routers/notifications.py`. `message` er allerede en ferdig, norsk
|
||||
tekst (samme snapshot-prinsipp som selve in-app-varselet) -- kun
|
||||
ramme-teksten (emne/hilsen/lenkeforklaring) er tospråklig her, ikke
|
||||
selve budskapet."""
|
||||
template = _NOTIFICATION_TEMPLATES.get(locale, _NOTIFICATION_TEMPLATES["nb"])
|
||||
link = f"{settings.PUBLIC_BASE_URL}{link_path}"
|
||||
body = template["body"].format(message=message, link=link)
|
||||
await to_thread(_send_sync, to_email, template["subject"], body)
|
||||
|
|
|
|||
|
|
@ -1,30 +1,47 @@
|
|||
"""
|
||||
In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25).
|
||||
In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25) +
|
||||
e-post-fallback per type (2026-07-28 oppfølging, migrasjon 033).
|
||||
|
||||
Eid av BRUKER (mottaker), ikke organisasjon -- plain_connection(), samme
|
||||
mønster som personlig profil/HCP-historikk/venner. `create_notification()`
|
||||
er den eneste skrivevegen inn -- kalt fra andre routere (i dag kun
|
||||
friends.py) ved konkrete hendelser, ikke noe generisk event-system.
|
||||
er den eneste skrivevegen inn -- kalt fra andre routere (friends.py,
|
||||
rounds.py) ved konkrete hendelser, ikke noe generisk event-system.
|
||||
|
||||
Trygg standard (samme filosofi som visibility_mode='private' ellers i
|
||||
appen): en bruker som ikke har gjort noe valg får ALDRI e-post for noen
|
||||
type -- kun `user_notification_email_pref`-raden EN BRUKER selv setter
|
||||
(via PUT /notifications/email-prefs) slår e-post PÅ for akkurat den typen.
|
||||
E-post-utsendingen skjer INNI `create_notification()` selv (ikke i hvert
|
||||
enkelt kallsted) -- driftsfeil i utsendingen skal ALDRI hindre selve
|
||||
in-app-varselet fra å bli opprettet, samme mønster (SMTP_CONFIGURED-sjekk
|
||||
+ try/except + traceback.print_exc()) som all annen e-post i appen
|
||||
(app/routers/auth.py, organizations.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth import CurrentUser, get_current_user
|
||||
from ..config import settings
|
||||
from ..db import plain_connection
|
||||
from ..email import send_notification_email
|
||||
|
||||
router = APIRouter(tags=["notifications"])
|
||||
|
||||
NotificationType = Literal["friend", "tournament", "round", "result"]
|
||||
_NOTIFICATION_TYPES: tuple[NotificationType, ...] = ("friend", "tournament", "round", "result")
|
||||
|
||||
|
||||
async def create_notification(conn, *, user_id: str, type: NotificationType, message: str, link_path: str) -> None:
|
||||
"""Kalles fra andre routere sin egen `plain_connection()`/transaksjon --
|
||||
tar en allerede-åpen `conn`, åpner ikke en egen."""
|
||||
tar en allerede-åpen `conn`, åpner ikke en egen. Sender i tillegg en
|
||||
e-post-fallback hvis (og kun hvis) mottakeren selv har valgt inn for
|
||||
akkurat DENNE typen."""
|
||||
await conn.execute(
|
||||
"INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)",
|
||||
user_id,
|
||||
|
|
@ -33,6 +50,65 @@ async def create_notification(conn, *, user_id: str, type: NotificationType, mes
|
|||
link_path,
|
||||
)
|
||||
|
||||
wants_email = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM user_notification_email_pref WHERE user_id = $1 AND type = $2)",
|
||||
user_id,
|
||||
type,
|
||||
)
|
||||
if not wants_email:
|
||||
return
|
||||
recipient = await conn.fetchrow(
|
||||
"SELECT email, preferred_locale FROM app_user WHERE id = $1 AND email IS NOT NULL",
|
||||
user_id,
|
||||
)
|
||||
if recipient is None:
|
||||
return
|
||||
if settings.SMTP_CONFIGURED:
|
||||
try:
|
||||
await send_notification_email(recipient["email"], message, link_path, recipient["preferred_locale"])
|
||||
except Exception:
|
||||
# Se modul-docstring: driftsfeil i utsendingen skal aldri
|
||||
# hindre selve in-app-varselet, som allerede er skrevet over.
|
||||
traceback.print_exc()
|
||||
elif settings.DEV_LOG_MAGIC_LINKS:
|
||||
print(f"[DEV] Varsel-e-post til {recipient['email']} ({type}): {message}", flush=True)
|
||||
|
||||
|
||||
class NotificationEmailPrefsOut(BaseModel):
|
||||
types: list[str]
|
||||
|
||||
|
||||
class NotificationEmailPrefsUpdate(BaseModel):
|
||||
types: list[NotificationType]
|
||||
|
||||
|
||||
@router.get("/notifications/email-prefs", response_model=NotificationEmailPrefsOut)
|
||||
async def get_email_prefs(user: CurrentUser = Depends(get_current_user)) -> NotificationEmailPrefsOut:
|
||||
async with plain_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT type FROM user_notification_email_pref WHERE user_id = $1",
|
||||
user.user_id,
|
||||
)
|
||||
return NotificationEmailPrefsOut(types=[r["type"] for r in rows])
|
||||
|
||||
|
||||
@router.put("/notifications/email-prefs", response_model=NotificationEmailPrefsOut)
|
||||
async def set_email_prefs(
|
||||
body: NotificationEmailPrefsUpdate, user: CurrentUser = Depends(get_current_user)
|
||||
) -> NotificationEmailPrefsOut:
|
||||
"""Full-erstatning, samme mønster som PUT /friends/{id}/categories --
|
||||
sender med en tom liste slår e-post AV for alle typer igjen."""
|
||||
types = sorted(set(body.types))
|
||||
async with plain_connection() as conn, conn.transaction():
|
||||
await conn.execute("DELETE FROM user_notification_email_pref WHERE user_id = $1", user.user_id)
|
||||
for t in types:
|
||||
await conn.execute(
|
||||
"INSERT INTO user_notification_email_pref (user_id, type) VALUES ($1, $2)",
|
||||
user.user_id,
|
||||
t,
|
||||
)
|
||||
return NotificationEmailPrefsOut(types=types)
|
||||
|
||||
|
||||
class NotificationOut(BaseModel):
|
||||
id: str
|
||||
|
|
|
|||
|
|
@ -1803,16 +1803,20 @@ async def add_participant(
|
|||
if body.round_side_id is not None:
|
||||
await _recompute_side_handicaps(conn, round_id, round_row["play_format"])
|
||||
|
||||
if linked_user_id is not None:
|
||||
owner_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
|
||||
round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"]
|
||||
await create_notification(
|
||||
conn,
|
||||
user_id=linked_user_id,
|
||||
type="round",
|
||||
message=f"{owner_name} la deg til som medspiller på en runde ({round_label}).",
|
||||
link_path=f"/my-rounds/{round_id}",
|
||||
)
|
||||
# Rundevarsel (2026-07-28) -- bevisst ETTER at transaksjonen er
|
||||
# begått, samme mønster som create_round/complete_round (en
|
||||
# e-post-utsending, se create_notification, skal aldri skje mens
|
||||
# en DB-transaksjon holder låser).
|
||||
if linked_user_id is not None:
|
||||
owner_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
|
||||
round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"]
|
||||
await create_notification(
|
||||
conn,
|
||||
user_id=linked_user_id,
|
||||
type="round",
|
||||
message=f"{owner_name} la deg til som medspiller på en runde ({round_label}).",
|
||||
link_path=f"/my-rounds/{round_id}",
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Link from "next/link"
|
|||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bell,
|
||||
Camera,
|
||||
KeyRound,
|
||||
Lock,
|
||||
|
|
@ -277,6 +278,8 @@ export function AccountSettings() {
|
|||
|
||||
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
|
||||
|
||||
<NotificationEmailPrefsSection />
|
||||
|
||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
|
|
@ -1315,3 +1318,101 @@ function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onC
|
|||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// --- E-post-fallback for varsler (2026-07-28 oppfølging) --------------------
|
||||
// Trygg standard: ingen typer valgt inntil brukeren selv velger inn --
|
||||
// PUT /notifications/email-prefs er en FULL erstatning (samme mønster som
|
||||
// venne-kategoriseringen), ikke en delvis PATCH.
|
||||
const NOTIFICATION_TYPE_OPTIONS: { value: string; label: string; description: string }[] = [
|
||||
{ value: "friend", label: "Venneforespørsler", description: "Noen sender deg en venneforespørsel, eller godtar din." },
|
||||
{ value: "round", label: "Runder", description: "Du blir lagt til som medspiller, eller en venn starter en runde du kan følge." },
|
||||
{ value: "result", label: "Resultater", description: "En runde du er koblet til blir fullført." },
|
||||
{ value: "tournament", label: "Turneringer", description: "Reservert for fremtidige turnering-varsler." },
|
||||
]
|
||||
|
||||
function NotificationEmailPrefsSection() {
|
||||
const [types, setTypes] = useState<string[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/notifications/email-prefs", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: { types: string[] } | null) => {
|
||||
if (data) setTypes(data.types)
|
||||
setLoaded(true)
|
||||
})
|
||||
.catch(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
async function save(next: string[]) {
|
||||
setTypes(next)
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
try {
|
||||
const res = await fetch("/notifications/email-prefs", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ types: next }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Klarte ikke å lagre varselinnstillingene.")
|
||||
setSuccess(true)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Bell aria-hidden="true" className="size-5 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Varsler på e-post</h2>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Du får alltid varsler i appen (klokke-ikonet). Velg her hvilke typer du i tillegg vil ha på
|
||||
e-post -- ingen sendes med mindre du krysser av.
|
||||
</p>
|
||||
|
||||
{loaded && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{NOTIFICATION_TYPE_OPTIONS.map((opt) => {
|
||||
const checked = types.includes(opt.value)
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex min-h-11 items-start gap-3 rounded-xl border px-3 py-2.5 ${
|
||||
checked ? "border-primary bg-primary/10" : "border-border"
|
||||
} ${saving ? "opacity-70" : "cursor-pointer"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={saving}
|
||||
onChange={(e) =>
|
||||
void save(e.target.checked ? [...types, opt.value] : types.filter((t) => t !== opt.value))
|
||||
}
|
||||
className="mt-0.5 size-4 shrink-0 rounded border-border"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground">{opt.label}</span>
|
||||
<span className="text-xs text-muted-foreground text-pretty">{opt.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||
{success && <p className="text-sm font-medium text-primary">Lagret.</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue