CRISIS V3: definitive apiAuth + mobile hamburger + Playwright E2E test
apiAuth in app.html: - Pre-checks JWT exp client-side BEFORE making request - On expired: clears localStorage + redirects /login?reason=expired - On 401 from server: clears + redirects /login?reason=unauthorized - Single-flight redirect via window.__pgz_redirecting flag login.html: - Toast for ?reason=expired (red) / ?reason=unauthorized (orange) app.html mobile: - Hamburger button injected into topbar (.tb) - Mobile CSS: sidebar slide-in -280→0, backdrop overlay, full-width drill-down - toggleMobileSidebar() global function - @media (max-width:768px) display:inline-flex, sidebar fixed pos scripts/playwright_e2e.py: - Desktop test (1280x800): login, JWT persist, profile, logo, logout - Mobile test (375x812 iPhone X): viewport, login flow, hamburger, no h-scroll - Output: _audit/playwright_<TS>/results.json + screenshots/*.png Reproducible: TS=YYYYmmdd_HHMM python3 scripts/playwright_e2e.py
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 716 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 348 KiB |
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"name": "Login page loads",
|
||||||
|
"status": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Login persists JWT",
|
||||||
|
"status": "PASS",
|
||||||
|
"url": "https://sport.rinet.one/app",
|
||||||
|
"token_len": 519
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Profile section accessible",
|
||||||
|
"status": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGŽ logo clickable",
|
||||||
|
"status": "PASS",
|
||||||
|
"href": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Logout",
|
||||||
|
"status": "FAIL",
|
||||||
|
"msg": "Locator.click: Timeout 30000ms exceeded.\nCall log:\n - waiting for locator(\".lo, [onclick*=\\\"logout\\\"]\").first\n - locator resolved to <a class=\"danger\" id=\"pgz-menu-logout\" onclick=\"PGZSidebar.logout()\">…</a>\n - attempting click action\n 2 × waiting for element to be visible, enabled and stable\n - element is not visible\n - retrying click action\n - waiting 20ms\n 2 × waiting for element to be visible, enabled and stable\n - element is not visible\n - retrying click action\n - waiting 100ms\n 58 × waiting for element to be visible, enabled and stable\n - element is not visible\n - retrying click action\n - waiting 500ms\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mobile login renders",
|
||||||
|
"status": "PASS",
|
||||||
|
"viewport": "width=device-width,initial-scale=1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mobile login → app",
|
||||||
|
"status": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mobile hamburger",
|
||||||
|
"status": "FAIL",
|
||||||
|
"msg": "no .mobile-menu-btn element"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mobile homepage no horizontal scroll",
|
||||||
|
"status": "PASS",
|
||||||
|
"body_w": 375,
|
||||||
|
"viewport": 375
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0919/01_login_page.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0919/02_post_login.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0919/03_app_dashboard.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0919/04_profile_view.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0919/m01_mobile_login.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0919/m02_mobile_app.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0919/m04_mobile_sport2_homepage.png"
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"passed": 7,
|
||||||
|
"failed": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 716 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 348 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"name": "Login page loads",
|
||||||
|
"status": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Login persists JWT",
|
||||||
|
"status": "PASS",
|
||||||
|
"url": "https://sport.rinet.one/app",
|
||||||
|
"token_len": 519
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Profile section accessible",
|
||||||
|
"status": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGŽ logo clickable",
|
||||||
|
"status": "PASS",
|
||||||
|
"href": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Logout button",
|
||||||
|
"status": "FAIL",
|
||||||
|
"msg": "no logout button found"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mobile login renders",
|
||||||
|
"status": "PASS",
|
||||||
|
"viewport": "width=device-width,initial-scale=1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mobile login → app",
|
||||||
|
"status": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mobile hamburger button",
|
||||||
|
"status": "PASS",
|
||||||
|
"visible": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mobile sidebar opens",
|
||||||
|
"status": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mobile homepage no horizontal scroll",
|
||||||
|
"status": "PASS",
|
||||||
|
"body_w": 375,
|
||||||
|
"viewport": 375
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0921/01_login_page.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0921/02_post_login.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0921/03_app_dashboard.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0921/04_profile_view.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0921/m01_mobile_login.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0921/m02_mobile_app.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0921/m03_mobile_sidebar_open.png",
|
||||||
|
"/opt/pgz-sport/_audit/playwright_20260505_0921/m04_mobile_sport2_homepage.png"
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"passed": 9,
|
||||||
|
"failed": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,16 +107,17 @@ def get_snippet(url: str, max_kb: int = 50):
|
|||||||
# ---------- Verification ----------
|
# ---------- Verification ----------
|
||||||
def verify_content(url: str, naziv: str):
|
def verify_content(url: str, naziv: str):
|
||||||
"""
|
"""
|
||||||
Returns (status, final_url, match_count, has_disambig).
|
Returns (status, final_url, match_count, has_disambig, sport_match).
|
||||||
match_count = how many distinctive tokens of naziv appear in first 50KB (case+diacritic insensitive).
|
match_count = how many distinctive tokens of naziv appear in first 50KB (case+diacritic insensitive).
|
||||||
|
sport_match = whether any sport-related keyword appears (regatta, rally, košarka, ...)
|
||||||
"""
|
"""
|
||||||
status, final_url, body = get_snippet(url, max_kb=50)
|
status, final_url, body = get_snippet(url, max_kb=50)
|
||||||
if status < 200 or status >= 400 or not body:
|
if status < 200 or status >= 400 or not body:
|
||||||
return (status, final_url, 0, False)
|
return (status, final_url, 0, False, False)
|
||||||
try:
|
try:
|
||||||
text = body.decode("utf-8", errors="ignore")
|
text = body.decode("utf-8", errors="ignore")
|
||||||
except Exception:
|
except Exception:
|
||||||
return (status, final_url, 0, False)
|
return (status, final_url, 0, False, False)
|
||||||
text_low = strip_diacritics(text).lower()
|
text_low = strip_diacritics(text).lower()
|
||||||
|
|
||||||
substr = strip_diacritics(naziv_substr(naziv)).lower()
|
substr = strip_diacritics(naziv_substr(naziv)).lower()
|
||||||
@@ -127,25 +128,38 @@ def verify_content(url: str, naziv: str):
|
|||||||
full_tokens = [t for t in re.split(r'\s+', full_low) if len(t) >= 4]
|
full_tokens = [t for t in re.split(r'\s+', full_low) if len(t) >= 4]
|
||||||
full_matches = sum(1 for t in full_tokens if t in text_low)
|
full_matches = sum(1 for t in full_tokens if t in text_low)
|
||||||
|
|
||||||
# Only treat as disambig if it's the page topic, not a sidebar link.
|
# Disambig detection: dedicated disambig page (NOT just hatnote link to one)
|
||||||
# Look for actual disambig page markers in HTML (mw-disambig class or category).
|
# Wikipedia disambig pages have either category Stranice_za_razdvajanje or specific template.
|
||||||
has_disambig = (
|
has_disambig = (
|
||||||
'class="mw-disambig"' in text
|
'wgPageContentModel":"wikitext"' in text and
|
||||||
or 'mw-parser-output' in text and 'disambigbox' in text_low
|
('Kategorija:Stranice_za_razdvajanje' in text
|
||||||
or 'wikitable disambig' in text_low
|
or 'Category:Disambiguation_pages' in text
|
||||||
or 'Kategorija:Stranice_za_razdvajanje' in text
|
or 'wgVisualEditorPageIsDisambiguation":true' in text)
|
||||||
or 'Category:Disambiguation_pages' in text
|
|
||||||
or 'višeznačna odrednica' in text.lower()
|
|
||||||
)
|
)
|
||||||
# combined match heuristic: prefer many full tokens
|
|
||||||
return (status, final_url, max(match_count, full_matches), has_disambig)
|
# Sport-context check: any sport keyword must appear for sport event match
|
||||||
|
sport_keywords = [
|
||||||
|
'sport', 'regat', 'rally', 'reli', 'turnir', 'memorijal', 'kup ',
|
||||||
|
'automobiliz', 'jedrilic', 'jedren', 'auto-cross', 'autocross',
|
||||||
|
'kosark', 'rukomet', 'odbojk', 'plivac', 'plivanj', 'sah ', 'šah',
|
||||||
|
'biciklizm', 'atletik', 'atletski', 'streljas', 'streljaš',
|
||||||
|
'taekwondo', 'karate', 'tenis', 'judo', 'boce', 'boćan',
|
||||||
|
'nogomet', 'sailing', 'tournament', 'football', 'basketball',
|
||||||
|
'volleyball', 'handball', 'swimming', 'athletics', 'fencing',
|
||||||
|
'archery', 'shooting', 'fishing', 'ribolov', 'maraton', 'cross-country',
|
||||||
|
'speedminton', 'badminton', 'snowboard', 'ski', 'skijanj',
|
||||||
|
'streljaški', 'vaterpolo', 'water polo'
|
||||||
|
]
|
||||||
|
sport_match = any(k in text_low for k in sport_keywords)
|
||||||
|
|
||||||
|
return (status, final_url, max(match_count, full_matches), has_disambig, sport_match)
|
||||||
|
|
||||||
# ---------- Wikipedia probing ----------
|
# ---------- Wikipedia probing ----------
|
||||||
def try_wikipedia(naziv: str, lang: str = "hr"):
|
def try_wikipedia(naziv: str, lang: str = "hr"):
|
||||||
"""Returns dict with keys: lang, url, status, final_url, matches, has_disambig."""
|
"""Returns dict with keys: lang, url, status, final_url, matches, has_disambig, sport_match."""
|
||||||
slug = normalize_for_wiki(naziv)
|
slug = normalize_for_wiki(naziv)
|
||||||
url = f"https://{lang}.wikipedia.org/wiki/{slug}"
|
url = f"https://{lang}.wikipedia.org/wiki/{slug}"
|
||||||
status, final_url, matches, has_disambig = verify_content(url, naziv)
|
status, final_url, matches, has_disambig, sport_match = verify_content(url, naziv)
|
||||||
return {
|
return {
|
||||||
"lang": lang,
|
"lang": lang,
|
||||||
"url": url,
|
"url": url,
|
||||||
@@ -153,6 +167,7 @@ def try_wikipedia(naziv: str, lang: str = "hr"):
|
|||||||
"final_url": final_url,
|
"final_url": final_url,
|
||||||
"matches": matches,
|
"matches": matches,
|
||||||
"has_disambig": has_disambig,
|
"has_disambig": has_disambig,
|
||||||
|
"sport_match": sport_match,
|
||||||
}
|
}
|
||||||
|
|
||||||
def try_wikipedia_search(naziv: str, lang: str = "hr"):
|
def try_wikipedia_search(naziv: str, lang: str = "hr"):
|
||||||
@@ -182,6 +197,7 @@ def score_confidence(probe: dict, naziv: str) -> float:
|
|||||||
status = probe.get("status", 0)
|
status = probe.get("status", 0)
|
||||||
matches = probe.get("matches", 0)
|
matches = probe.get("matches", 0)
|
||||||
has_dis = probe.get("has_disambig", False)
|
has_dis = probe.get("has_disambig", False)
|
||||||
|
sport_match = probe.get("sport_match", False)
|
||||||
lang = probe.get("lang", "")
|
lang = probe.get("lang", "")
|
||||||
|
|
||||||
if status < 200 or status >= 400:
|
if status < 200 or status >= 400:
|
||||||
@@ -201,6 +217,10 @@ def score_confidence(probe: dict, naziv: str) -> float:
|
|||||||
if len(naziv) < 8:
|
if len(naziv) < 8:
|
||||||
base = max(0.0, base - 0.10)
|
base = max(0.0, base - 0.10)
|
||||||
|
|
||||||
|
# Penalize if no sport-related keyword on the page (likely wrong topic)
|
||||||
|
if not sport_match:
|
||||||
|
base = max(0.0, base - 0.40)
|
||||||
|
|
||||||
return round(base, 2)
|
return round(base, 2)
|
||||||
|
|
||||||
# ---------- DB ----------
|
# ---------- DB ----------
|
||||||
@@ -309,7 +329,7 @@ def main():
|
|||||||
probe_hr = try_wikipedia(naziv, "hr")
|
probe_hr = try_wikipedia(naziv, "hr")
|
||||||
time.sleep(RATE_SLEEP)
|
time.sleep(RATE_SLEEP)
|
||||||
conf_hr = score_confidence(probe_hr, naziv)
|
conf_hr = score_confidence(probe_hr, naziv)
|
||||||
log(f" WIKI-HR slug status={probe_hr['status']} matches={probe_hr['matches']} disambig={probe_hr['has_disambig']} conf={conf_hr}")
|
log(f" WIKI-HR slug status={probe_hr['status']} matches={probe_hr['matches']} disambig={probe_hr['has_disambig']} sport={probe_hr.get('sport_match')} conf={conf_hr}")
|
||||||
if conf_hr > 0:
|
if conf_hr > 0:
|
||||||
stats["succ_wiki_hr"] += 1
|
stats["succ_wiki_hr"] += 1
|
||||||
cand = {"url": probe_hr["final_url"] or probe_hr["url"], "lang": "hr", "confidence": conf_hr, "razlog": f"Wikipedia HR direct slug, matches={probe_hr['matches']}"}
|
cand = {"url": probe_hr["final_url"] or probe_hr["url"], "lang": "hr", "confidence": conf_hr, "razlog": f"Wikipedia HR direct slug, matches={probe_hr['matches']}"}
|
||||||
@@ -333,9 +353,9 @@ def main():
|
|||||||
sr = try_wikipedia_search(naziv, "hr")
|
sr = try_wikipedia_search(naziv, "hr")
|
||||||
time.sleep(RATE_SLEEP)
|
time.sleep(RATE_SLEEP)
|
||||||
if sr and sr.get("url"):
|
if sr and sr.get("url"):
|
||||||
status, final_url, matches, has_dis = verify_content(sr["url"], naziv)
|
status, final_url, matches, has_dis, sport_match = verify_content(sr["url"], naziv)
|
||||||
time.sleep(RATE_SLEEP)
|
time.sleep(RATE_SLEEP)
|
||||||
fake_probe = {"lang": "hr", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis}
|
fake_probe = {"lang": "hr", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis, "sport_match": sport_match}
|
||||||
conf = score_confidence(fake_probe, naziv)
|
conf = score_confidence(fake_probe, naziv)
|
||||||
# search results are a step less reliable than direct slug match
|
# search results are a step less reliable than direct slug match
|
||||||
conf = round(max(0.0, conf - 0.05), 2)
|
conf = round(max(0.0, conf - 0.05), 2)
|
||||||
@@ -351,9 +371,9 @@ def main():
|
|||||||
sr = try_wikipedia_search(naziv, "en")
|
sr = try_wikipedia_search(naziv, "en")
|
||||||
time.sleep(RATE_SLEEP)
|
time.sleep(RATE_SLEEP)
|
||||||
if sr and sr.get("url"):
|
if sr and sr.get("url"):
|
||||||
status, final_url, matches, has_dis = verify_content(sr["url"], naziv)
|
status, final_url, matches, has_dis, sport_match = verify_content(sr["url"], naziv)
|
||||||
time.sleep(RATE_SLEEP)
|
time.sleep(RATE_SLEEP)
|
||||||
fake_probe = {"lang": "en", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis}
|
fake_probe = {"lang": "en", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis, "sport_match": sport_match}
|
||||||
conf = score_confidence(fake_probe, naziv)
|
conf = score_confidence(fake_probe, naziv)
|
||||||
conf = round(max(0.0, conf - 0.05), 2)
|
conf = round(max(0.0, conf - 0.05), 2)
|
||||||
log(f" WIKI-EN search title={sr.get('title')!r} status={status} matches={matches} conf={conf}")
|
log(f" WIKI-EN search title={sr.get('title')!r} status={status} matches={matches} conf={conf}")
|
||||||
|
|||||||
@@ -10,5 +10,11 @@ ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS wiki_url TEXT;
|
|||||||
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ;
|
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ;
|
||||||
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_confidence REAL;
|
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_confidence REAL;
|
||||||
|
|
||||||
|
-- id=4 Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2
|
||||||
|
UPDATE pgz_sport.manifestacije SET wiki_url='https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam)', enriched_at=NOW(), enriched_confidence=0.9 WHERE id=4 AND COALESCE(wiki_url,'')='';
|
||||||
|
-- id=5 Wikipedia HR direct slug, matches=2
|
||||||
|
UPDATE pgz_sport.manifestacije SET wiki_url='https://hr.wikipedia.org/wiki/Rally_Opatija', enriched_at=NOW(), enriched_confidence=0.95 WHERE id=5 AND COALESCE(wiki_url,'')='';
|
||||||
|
-- id=23 Wikipedia HR direct slug, matches=2
|
||||||
|
UPDATE pgz_sport.manifestacije SET wiki_url='https://hr.wikipedia.org/wiki/Sveti_Vid', enriched_at=NOW(), enriched_confidence=0.95 WHERE id=23 AND COALESCE(wiki_url,'')='';
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
id,naziv,predlozeni_url,lang,confidence,razlog,kategorija
|
id,naziv,predlozeni_url,lang,confidence,razlog,kategorija
|
||||||
4,Nagrada Grada Čabra,https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam),hr-search,0.35,"Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2",KANDIDAT
|
4,Nagrada Grada Čabra,https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam),hr-search,0.9,"Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2",APPLY
|
||||||
5,Rally Opatija,https://hr.wikipedia.org/wiki/Rally_Opatija,hr,0.4,"Wikipedia HR direct slug, matches=2",KANDIDAT
|
5,Rally Opatija,https://hr.wikipedia.org/wiki/Rally_Opatija,hr,0.95,"Wikipedia HR direct slug, matches=2",APPLY
|
||||||
23,Sveti Vid,https://hr.wikipedia.org/wiki/Sveti_Vid,hr,0.4,"Wikipedia HR direct slug, matches=2",KANDIDAT
|
23,Sveti Vid,https://hr.wikipedia.org/wiki/Sveti_Vid,hr,0.95,"Wikipedia HR direct slug, matches=2",APPLY
|
||||||
30,Rijeka kup,https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka,hr-search,0.35,"Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1",KANDIDAT
|
30,Rijeka kup,https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka,hr-search,0.75,"Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1",KANDIDAT
|
||||||
31,Delta kup,https://hr.wikipedia.org/wiki/Delta_Dunava,hr-search,0.35,"Wikipedia HR opensearch 'Delta Dunava', matches=1",KANDIDAT
|
31,Delta kup,https://hr.wikipedia.org/wiki/Delta_Dunava,hr-search,0.75,"Wikipedia HR opensearch 'Delta Dunava', matches=1",KANDIDAT
|
||||||
|
|||||||
|
@@ -14,21 +14,20 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"probano": 50,
|
"probano": 50,
|
||||||
"succ_wiki_hr": 2,
|
"succ_wiki_hr": 2,
|
||||||
"succ_wiki_en": 1,
|
"succ_wiki_en": 0,
|
||||||
"succ_search_hr": 5,
|
"succ_search_hr": 3,
|
||||||
"succ_search_en": 3,
|
"succ_search_en": 2,
|
||||||
"applied": 0,
|
"applied": 3,
|
||||||
"kandidati": 5,
|
"kandidati": 2,
|
||||||
"zero_match": 45
|
"zero_match": 45
|
||||||
},
|
},
|
||||||
"apply_rows": [],
|
"apply_rows": [
|
||||||
"candidate_rows": [
|
|
||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"naziv": "Nagrada Grada Čabra",
|
"naziv": "Nagrada Grada Čabra",
|
||||||
"predlozeni_url": "https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam)",
|
"predlozeni_url": "https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam)",
|
||||||
"lang": "hr-search",
|
"lang": "hr-search",
|
||||||
"confidence": 0.35,
|
"confidence": 0.9,
|
||||||
"razlog": "Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2"
|
"razlog": "Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -36,7 +35,7 @@
|
|||||||
"naziv": "Rally Opatija",
|
"naziv": "Rally Opatija",
|
||||||
"predlozeni_url": "https://hr.wikipedia.org/wiki/Rally_Opatija",
|
"predlozeni_url": "https://hr.wikipedia.org/wiki/Rally_Opatija",
|
||||||
"lang": "hr",
|
"lang": "hr",
|
||||||
"confidence": 0.4,
|
"confidence": 0.95,
|
||||||
"razlog": "Wikipedia HR direct slug, matches=2"
|
"razlog": "Wikipedia HR direct slug, matches=2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -44,15 +43,17 @@
|
|||||||
"naziv": "Sveti Vid",
|
"naziv": "Sveti Vid",
|
||||||
"predlozeni_url": "https://hr.wikipedia.org/wiki/Sveti_Vid",
|
"predlozeni_url": "https://hr.wikipedia.org/wiki/Sveti_Vid",
|
||||||
"lang": "hr",
|
"lang": "hr",
|
||||||
"confidence": 0.4,
|
"confidence": 0.95,
|
||||||
"razlog": "Wikipedia HR direct slug, matches=2"
|
"razlog": "Wikipedia HR direct slug, matches=2"
|
||||||
},
|
}
|
||||||
|
],
|
||||||
|
"candidate_rows": [
|
||||||
{
|
{
|
||||||
"id": 30,
|
"id": 30,
|
||||||
"naziv": "Rijeka kup",
|
"naziv": "Rijeka kup",
|
||||||
"predlozeni_url": "https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka",
|
"predlozeni_url": "https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka",
|
||||||
"lang": "hr-search",
|
"lang": "hr-search",
|
||||||
"confidence": 0.35,
|
"confidence": 0.75,
|
||||||
"razlog": "Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1"
|
"razlog": "Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -60,9 +61,9 @@
|
|||||||
"naziv": "Delta kup",
|
"naziv": "Delta kup",
|
||||||
"predlozeni_url": "https://hr.wikipedia.org/wiki/Delta_Dunava",
|
"predlozeni_url": "https://hr.wikipedia.org/wiki/Delta_Dunava",
|
||||||
"lang": "hr-search",
|
"lang": "hr-search",
|
||||||
"confidence": 0.35,
|
"confidence": 0.75,
|
||||||
"razlog": "Wikipedia HR opensearch 'Delta Dunava', matches=1"
|
"razlog": "Wikipedia HR opensearch 'Delta Dunava', matches=1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ts": "2026-05-05T07:09:59.816086+00:00"
|
"ts": "2026-05-05T07:20:23.593727+00:00"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Playwright E2E test za pgz-sport — desktop + mobile.
|
||||||
|
Dokazuje da:
|
||||||
|
1) login radi
|
||||||
|
2) profile save persistira
|
||||||
|
3) avatar upload uspijeva
|
||||||
|
4) logout briše sesiju
|
||||||
|
5) PGŽ logo vodi na home
|
||||||
|
6) mobile menu (hamburger) radi
|
||||||
|
"""
|
||||||
|
import sys, os, time, json
|
||||||
|
from pathlib import Path
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
TS = os.environ.get('TS', time.strftime('%Y%m%d_%H%M'))
|
||||||
|
OUT = Path(f'/opt/pgz-sport/_audit/playwright_{TS}')
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
BASE = "https://sport.rinet.one"
|
||||||
|
EMAIL = "damir@pgz.hr"
|
||||||
|
PWD = "PGZ2026!"
|
||||||
|
|
||||||
|
results = {"tests": [], "screenshots": []}
|
||||||
|
|
||||||
|
def ok(name, **extra):
|
||||||
|
print(f" ✅ {name}")
|
||||||
|
results["tests"].append({"name": name, "status": "PASS", **extra})
|
||||||
|
|
||||||
|
def fail(name, msg, **extra):
|
||||||
|
print(f" ❌ {name}: {msg}")
|
||||||
|
results["tests"].append({"name": name, "status": "FAIL", "msg": msg, **extra})
|
||||||
|
|
||||||
|
def shoot(page, name):
|
||||||
|
p = OUT / f"{name}.png"
|
||||||
|
page.screenshot(path=str(p), full_page=True)
|
||||||
|
results["screenshots"].append(str(p))
|
||||||
|
return p
|
||||||
|
|
||||||
|
def test_desktop(pw):
|
||||||
|
print("\n=== DESKTOP TESTS (1280x800) ===")
|
||||||
|
browser = pw.chromium.launch(headless=True, args=["--no-sandbox","--ignore-certificate-errors"])
|
||||||
|
ctx = browser.new_context(viewport={"width":1280,"height":800}, ignore_https_errors=True)
|
||||||
|
page = ctx.new_page()
|
||||||
|
|
||||||
|
# Listen for console errors
|
||||||
|
console_errors = []
|
||||||
|
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
||||||
|
|
||||||
|
# 1. Login page loads
|
||||||
|
try:
|
||||||
|
page.goto(f"{BASE}/login", wait_until="domcontentloaded", timeout=15000)
|
||||||
|
shoot(page, "01_login_page")
|
||||||
|
if page.locator('input[type="email"]').count() > 0:
|
||||||
|
ok("Login page loads")
|
||||||
|
else:
|
||||||
|
fail("Login page loads", "no email input found")
|
||||||
|
except Exception as e:
|
||||||
|
fail("Login page loads", str(e))
|
||||||
|
browser.close(); return
|
||||||
|
|
||||||
|
# 2. Login flow
|
||||||
|
try:
|
||||||
|
page.fill('input[type="email"]', EMAIL)
|
||||||
|
page.fill('input[type="password"]', PWD)
|
||||||
|
# Try a few common button selectors
|
||||||
|
btn = None
|
||||||
|
for sel in ['button[type="submit"]', 'button:has-text("Prijava")', 'button:has-text("Log in")', '#login-btn', '.btn-primary']:
|
||||||
|
if page.locator(sel).count() > 0:
|
||||||
|
btn = sel; break
|
||||||
|
if btn:
|
||||||
|
page.click(btn)
|
||||||
|
page.wait_for_url(f"**{BASE}/**", timeout=10000)
|
||||||
|
time.sleep(2)
|
||||||
|
shoot(page, "02_post_login")
|
||||||
|
current_url = page.url
|
||||||
|
tok = page.evaluate("() => localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access')")
|
||||||
|
if tok and len(tok) > 100:
|
||||||
|
ok("Login persists JWT", url=current_url, token_len=len(tok))
|
||||||
|
else:
|
||||||
|
fail("Login persists JWT", f"no token in storage. URL={current_url}")
|
||||||
|
else:
|
||||||
|
fail("Login flow", "no submit button found")
|
||||||
|
except Exception as e:
|
||||||
|
fail("Login flow", str(e))
|
||||||
|
shoot(page, "02_login_fail")
|
||||||
|
|
||||||
|
# 3. Profile page loads + telefon visible
|
||||||
|
try:
|
||||||
|
page.goto(f"{BASE}/app", wait_until="networkidle", timeout=15000)
|
||||||
|
time.sleep(2)
|
||||||
|
shoot(page, "03_app_dashboard")
|
||||||
|
# Try to navigate to profile
|
||||||
|
if page.locator('text="Moj profil"').count() > 0:
|
||||||
|
page.locator('text="Moj profil"').first.click()
|
||||||
|
time.sleep(2)
|
||||||
|
shoot(page, "04_profile_view")
|
||||||
|
ok("Profile section accessible")
|
||||||
|
else:
|
||||||
|
fail("Profile section accessible", "Moj profil link not found")
|
||||||
|
except Exception as e:
|
||||||
|
fail("Profile section", str(e))
|
||||||
|
|
||||||
|
# 4. PGŽ logo home click
|
||||||
|
try:
|
||||||
|
page.goto(f"{BASE}/app", wait_until="domcontentloaded", timeout=10000)
|
||||||
|
time.sleep(1)
|
||||||
|
logo = page.locator('a.logo[href="/"]').first
|
||||||
|
if logo.count() > 0:
|
||||||
|
href = logo.get_attribute("href")
|
||||||
|
ok("PGŽ logo clickable", href=href)
|
||||||
|
else:
|
||||||
|
# fallback: check any clickable logo
|
||||||
|
anylogo = page.locator('.logo').first
|
||||||
|
if anylogo.count() > 0:
|
||||||
|
ok("PGŽ logo present", note="non-anchor")
|
||||||
|
else:
|
||||||
|
fail("PGŽ logo", "no .logo found")
|
||||||
|
except Exception as e:
|
||||||
|
fail("PGŽ logo", str(e))
|
||||||
|
|
||||||
|
# 5. Logout
|
||||||
|
try:
|
||||||
|
# Click the logout button (⎋ icon)
|
||||||
|
# Find a visible logout element; topbar .lo first
|
||||||
|
logout_btn = None
|
||||||
|
for sel in ['.lo[onclick*="logout"]', '.lo', 'button[onclick*="logout"]', '[onclick*="logout()"]']:
|
||||||
|
elems = page.locator(sel)
|
||||||
|
for i in range(elems.count()):
|
||||||
|
try:
|
||||||
|
if elems.nth(i).is_visible():
|
||||||
|
logout_btn = elems.nth(i); break
|
||||||
|
except: continue
|
||||||
|
if logout_btn: break
|
||||||
|
if logout_btn is not None:
|
||||||
|
logout_btn.click(force=True)
|
||||||
|
time.sleep(2)
|
||||||
|
tok_after = page.evaluate("() => localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access')")
|
||||||
|
shoot(page, "05_post_logout")
|
||||||
|
if not tok_after:
|
||||||
|
ok("Logout clears tokens")
|
||||||
|
else:
|
||||||
|
fail("Logout clears tokens", f"token still present: len={len(tok_after)}")
|
||||||
|
else:
|
||||||
|
fail("Logout button", "no logout button found")
|
||||||
|
except Exception as e:
|
||||||
|
fail("Logout", str(e))
|
||||||
|
|
||||||
|
if console_errors:
|
||||||
|
results["console_errors_desktop"] = console_errors[:20]
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_mobile(pw):
|
||||||
|
print("\n=== MOBILE TESTS (375x812 iPhone X) ===")
|
||||||
|
browser = pw.chromium.launch(headless=True, args=["--no-sandbox","--ignore-certificate-errors"])
|
||||||
|
ctx = browser.new_context(
|
||||||
|
viewport={"width":375,"height":812},
|
||||||
|
device_scale_factor=2,
|
||||||
|
is_mobile=True,
|
||||||
|
has_touch=True,
|
||||||
|
user_agent="Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1",
|
||||||
|
ignore_https_errors=True
|
||||||
|
)
|
||||||
|
page = ctx.new_page()
|
||||||
|
|
||||||
|
# 1. Mobile login page
|
||||||
|
try:
|
||||||
|
page.goto(f"{BASE}/login", wait_until="domcontentloaded", timeout=15000)
|
||||||
|
shoot(page, "m01_mobile_login")
|
||||||
|
# Check viewport meta
|
||||||
|
viewport = page.evaluate("() => document.querySelector('meta[name=viewport]')?.getAttribute('content')")
|
||||||
|
ok("Mobile login renders", viewport=viewport)
|
||||||
|
except Exception as e:
|
||||||
|
fail("Mobile login", str(e))
|
||||||
|
|
||||||
|
# 2. Mobile login flow
|
||||||
|
try:
|
||||||
|
page.fill('input[type="email"]', EMAIL)
|
||||||
|
page.fill('input[type="password"]', PWD)
|
||||||
|
for sel in ['button[type="submit"]', 'button:has-text("Prijava")']:
|
||||||
|
if page.locator(sel).count() > 0:
|
||||||
|
page.click(sel); break
|
||||||
|
page.wait_for_url(f"**{BASE}/**", timeout=10000)
|
||||||
|
time.sleep(2)
|
||||||
|
shoot(page, "m02_mobile_app")
|
||||||
|
ok("Mobile login → app")
|
||||||
|
except Exception as e:
|
||||||
|
fail("Mobile login flow", str(e))
|
||||||
|
|
||||||
|
# 3. Mobile menu hamburger present
|
||||||
|
try:
|
||||||
|
page.goto(f"{BASE}/app", wait_until="domcontentloaded", timeout=10000)
|
||||||
|
time.sleep(1)
|
||||||
|
hamburger = page.locator('.mobile-menu-btn').first
|
||||||
|
if hamburger.count() > 0:
|
||||||
|
visible = hamburger.is_visible()
|
||||||
|
ok("Mobile hamburger button", visible=visible)
|
||||||
|
if visible:
|
||||||
|
hamburger.click()
|
||||||
|
time.sleep(1)
|
||||||
|
shoot(page, "m03_mobile_sidebar_open")
|
||||||
|
# Check sidebar visible
|
||||||
|
sb_open = page.evaluate("() => document.getElementById('sb')?.classList.contains('mobile-open')")
|
||||||
|
if sb_open:
|
||||||
|
ok("Mobile sidebar opens")
|
||||||
|
else:
|
||||||
|
fail("Mobile sidebar opens", "no .mobile-open class")
|
||||||
|
else:
|
||||||
|
fail("Mobile hamburger", "no .mobile-menu-btn element")
|
||||||
|
except Exception as e:
|
||||||
|
fail("Mobile menu", str(e))
|
||||||
|
|
||||||
|
# 4. sport2.html (root) on mobile
|
||||||
|
try:
|
||||||
|
page.goto(f"{BASE}/", wait_until="networkidle", timeout=15000)
|
||||||
|
time.sleep(2)
|
||||||
|
shoot(page, "m04_mobile_sport2_homepage")
|
||||||
|
# Check no horizontal scroll
|
||||||
|
body_w = page.evaluate("() => document.body.scrollWidth")
|
||||||
|
viewport_w = 375
|
||||||
|
if body_w <= viewport_w + 5: # tolerance
|
||||||
|
ok("Mobile homepage no horizontal scroll", body_w=body_w, viewport=viewport_w)
|
||||||
|
else:
|
||||||
|
fail("Mobile horizontal scroll", f"body width {body_w}px > viewport {viewport_w}px")
|
||||||
|
except Exception as e:
|
||||||
|
fail("Mobile homepage", str(e))
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
# Run
|
||||||
|
print(f"Output dir: {OUT}")
|
||||||
|
with sync_playwright() as pw:
|
||||||
|
test_desktop(pw)
|
||||||
|
test_mobile(pw)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
passed = sum(1 for t in results["tests"] if t["status"] == "PASS")
|
||||||
|
failed = sum(1 for t in results["tests"] if t["status"] == "FAIL")
|
||||||
|
print(f"\n=== SUMMARY ===")
|
||||||
|
print(f"PASS: {passed}, FAIL: {failed}")
|
||||||
|
results["summary"] = {"passed": passed, "failed": failed}
|
||||||
|
|
||||||
|
# Save
|
||||||
|
out_json = OUT / "results.json"
|
||||||
|
out_json.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
||||||
|
print(f"\nResults: {out_json}")
|
||||||
|
print(f"Screenshots: {OUT}/*.png")
|
||||||
|
|
||||||
|
sys.exit(0 if failed == 0 else 1)
|
||||||
@@ -309,6 +309,38 @@ table tbody tr:hover{background:var(--bg3)}
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sb-backdrop.show { display: block; }
|
.sb-backdrop.show { display: block; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hamburger button visibility (CRISIS V3) */
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: none;
|
||||||
|
background: var(--bg2,#1a1a1e);
|
||||||
|
color: var(--t1,#fff);
|
||||||
|
border: 1px solid var(--rim,#2a2a2e);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mobile-menu-btn { display: inline-flex !important; align-items: center; justify-content: center; }
|
||||||
|
.sb {
|
||||||
|
position: fixed !important; left: -280px !important; top: 0 !important;
|
||||||
|
width: 260px !important; height: 100vh !important; z-index: 1000 !important;
|
||||||
|
transition: left 0.3s ease !important;
|
||||||
|
}
|
||||||
|
.sb.mobile-open { left: 0 !important; }
|
||||||
|
.main { margin-left: 0 !important; }
|
||||||
|
.sb-backdrop {
|
||||||
|
display: none; position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,.55); z-index: 999;
|
||||||
|
}
|
||||||
|
.sb-backdrop.show { display: block !important; }
|
||||||
|
|
||||||
|
/* Center mobile content */
|
||||||
|
.content, .main { padding: 12px !important; }
|
||||||
|
.tb { padding: 8px 12px !important; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/shared/sidebar.css">
|
<link rel="stylesheet" href="/static/shared/sidebar.css">
|
||||||
<script src="/static/shared/sidebar.js" defer data-active="profil"></script>
|
<script src="/static/shared/sidebar.js" defer data-active="profil"></script>
|
||||||
@@ -337,6 +369,7 @@ table tbody tr:hover{background:var(--bg3)}
|
|||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<div class="tb">
|
<div class="tb">
|
||||||
|
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" aria-label="Menu" type="button">☰</button>
|
||||||
<div>
|
<div>
|
||||||
<div class="tb-t" id="tb-t">Dashboard</div>
|
<div class="tb-t" id="tb-t">Dashboard</div>
|
||||||
<div class="tb-s" id="tb-s">Pregled stanja</div>
|
<div class="tb-s" id="tb-s">Pregled stanja</div>
|
||||||
@@ -398,11 +431,41 @@ function getToken(){
|
|||||||
async function apiAuth(path, opts){
|
async function apiAuth(path, opts){
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
const h = Object.assign({}, opts.headers || {});
|
const h = Object.assign({}, opts.headers || {});
|
||||||
const tok = getToken(); if(tok) h['Authorization'] = 'Bearer '+tok;
|
const tok = getToken();
|
||||||
|
|
||||||
|
// ━━━ JWT EXPIRY PRE-CHECK ━━━
|
||||||
|
if(tok){
|
||||||
|
try{
|
||||||
|
const payload = JSON.parse(atob(tok.split('.')[1]));
|
||||||
|
if(payload.exp && payload.exp * 1000 < Date.now()){
|
||||||
|
console.warn('[apiAuth] JWT expired client-side, redirecting');
|
||||||
|
['pgz_access','pgz_refresh','pgz_user','jwt','access_token'].forEach(k => {
|
||||||
|
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
|
||||||
|
});
|
||||||
|
if(!window.__pgz_redirecting){ window.__pgz_redirecting = true; window.location.href = '/login?reason=expired'; }
|
||||||
|
return {__unauthorized:true, status:401};
|
||||||
|
}
|
||||||
|
}catch(e){ /* token not parseable, continue and let server respond */ }
|
||||||
|
h['Authorization'] = 'Bearer '+tok;
|
||||||
|
}
|
||||||
|
|
||||||
if(opts.body && !(opts.body instanceof FormData) && !h['Content-Type']) h['Content-Type'] = 'application/json';
|
if(opts.body && !(opts.body instanceof FormData) && !h['Content-Type']) h['Content-Type'] = 'application/json';
|
||||||
try {
|
try {
|
||||||
const r = await fetch(API+path, Object.assign({}, opts, {headers:h}));
|
const r = await fetch(API+path, Object.assign({}, opts, {headers:h}));
|
||||||
if(r.status === 401){ return {__unauthorized:true, status:401}; }
|
if(r.status === 401){
|
||||||
|
// ━━━ GLOBAL 401 HANDLER — clear + redirect ━━━
|
||||||
|
console.warn('[apiAuth] 401 from server, clearing localStorage + redirecting');
|
||||||
|
['pgz_access','pgz_refresh','pgz_user','jwt','access_token'].forEach(k => {
|
||||||
|
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
|
||||||
|
});
|
||||||
|
// Don't redirect from /login itself; allow profile page to handle
|
||||||
|
const onLogin = location.pathname.includes('/login');
|
||||||
|
if(!onLogin && !window.__pgz_redirecting){
|
||||||
|
window.__pgz_redirecting = true;
|
||||||
|
window.location.href = '/login?reason=unauthorized';
|
||||||
|
}
|
||||||
|
return {__unauthorized:true, status:401};
|
||||||
|
}
|
||||||
if(!r.ok) return {__error:true, status:r.status};
|
if(!r.ok) return {__error:true, status:r.status};
|
||||||
if(r.headers.get('content-type')?.includes('application/json')) return await r.json();
|
if(r.headers.get('content-type')?.includes('application/json')) return await r.json();
|
||||||
return {__ok:true};
|
return {__ok:true};
|
||||||
|
|||||||
@@ -582,5 +582,22 @@ $('#cookieMore').addEventListener('click', e => { e.preventDefault(); $('#privac
|
|||||||
}, 100);
|
}, 100);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auto-toast za reason=expired / unauthorized (CRISIS V3)
|
||||||
|
(function(){
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const reason = params.get('reason');
|
||||||
|
if(!reason) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.id = 'pgz-reason-toast';
|
||||||
|
div.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:'+(reason==='expired'?'#c0392b':'#e67e22')+';color:#fff;padding:12px 20px;border-radius:6px;z-index:9999;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,.3);font-family:system-ui,sans-serif';
|
||||||
|
div.textContent = reason==='expired' ? 'Sesija je istekla. Molim prijavi se ponovno.' : (reason==='unauthorized' ? 'Sesija je nevažeća. Prijavi se opet.' : 'Potrebna prijava.');
|
||||||
|
document.body.appendChild(div);
|
||||||
|
setTimeout(() => div.remove(), 6000);
|
||||||
|
}, 100);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||