Proje: Okul Platform · Hub: Okul Platform — Conventions

OTP value field: accept email OR TR phone

The school-api OTP endpoints (/api/auth/otp, /api/auth/login) accept a value field that is intentionally string, max:255 on the Laravel Request (backend lang file has email_or_phone rule key but it is NOT enforced in GenerateOtpCodeRequest / UserLoginRequest). The external API does the real validation.

FE must validate and normalize before POST to avoid junk reaching the API.

Detection

  • Contains @ → treat as email
  • Otherwise → treat as Turkish mobile phone

Regex

email: ^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}$
phone (after stripping non-digits): ^(0|90)?5\d{9}$

Valid TR mobile shapes (all strip to 5XXXXXXXXX then match): 05551234567, +905551234567, 5551234567, 0 (555) 123 45 67 (masked).

Normalization before POST

  • email → trim().toLowerCase()
  • phone → trim().replace(/\D/g, '') (digits-only)

Send the normalized form, not the raw user input. Masked phone values like 0 (555) 123 45 67 MUST be stripped — backend/external API expects clean digits.

OTP code field

  • Fixed 4 digits (provider sends 9918-style codes): ^\d{4}$
  • Input filter: strip non-digits on every input event, cap at 4 chars
  • maxlength="4", inputmode="numeric", autocomplete="one-time-code"
  • Style: center-aligned, letter-spacing: 0.4em, font-size: 20px (PIN-look)
  • No placeholder — label alone is enough; text-placeholder on a PIN field looks jarring.

Code validity countdown + resend gating

  • Default code validity: 180 seconds (if API response contains expires_in / duration / ttl, use that; clamp to [1, 3600]).
  • After successful POST /auth/otp, show countdown "Kod geçerlilik süresi: M:SS" below the code input. On 0, flip to red "Kod süresi doldu" and reveal the resend link.
  • Resend link is HIDDEN while countdown is active — user cannot spam re-requests while a valid code is outstanding. Only after expiry does the resend link appear.
  • On successful verify: clear countdown + localStorage.
  • On modal close / method switch: stop countdown (prevents leaked interval).

Session persistence across page reloads

The external OTP code stays valid server-side for the full duration. If the user refreshes mid-flow, the FE must resume rather than reset (otherwise user sees “send OTP” UI even though a valid code is already in their SMS).

Scheme: localStorage['otp_session'] = {value: normalizedValue, sentAt: Date.now(), duration: seconds}

  • Write on successful OTP request.
  • Clear on verify success, countdown expiry, or explicit new request.
  • On DOMContentLoaded: if unexpired session exists, auto-switch to OTP method, restore value field, show code input, continue countdown with remaining seconds.

Error response parsing

Backend proxies the external API’s error body verbatim. Response shape varies — may be {message: "..."}, {error: "..."}, {error: {message: "..."}}, Laravel-style {errors: {field: ["..."]}}, or plain text. FE must try each in order; never fall through to a generic message if a specific one is available.

Order: responseJSON.message.error (str or .message) → .errors[firstField][0] → plain responseText (if short + not HTML) → status-specific defaults (429 → “Çok fazla deneme…“) → generic fallback.

Why: user-reported issue 2026-04-14 — generic “Bir hata oluştu” was shown even though backend returned the much more actionable “Tek seferlik şifreniz iletilmiştir. Lütfen daha sonra tekrar deneyin”. The specific message reveals the root cause (rate limit / code still valid); the generic one leaves the user blind.

Input-time restriction (not only submit-time)

Submit validation alone is user-hostile — user typed 21 digits then got rejected. Also restrict at input event:

value field:

  • HTML maxlength="100" (safety cap)
  • On input: if value contains a letter or @ → email mode, cap at 100 chars. Else → phone mode, strip non-digits and cap at 11 (matches placeholder 05XXXXXXXXX exactly — TR mobile).
  • Phone regex: ^0?5\d{9}$ (10 or 11 digits, starts with 5 or 05).
  • +90 international prefix NOT supported by the input cap — if user pastes +905551234567, the last digit gets truncated. Acceptable tradeoff for domestic-first UX. If international support needed later, reintroduce the (0|90)? prefix and raise the cap to 12.

Key principle: cap must equal the placeholder length. Earlier revision used cap 13 (for +90 support) but placeholder was 05XXXXXXXXX (11 chars) — users saw “I typed what the placeholder said and 2 extra characters still slipped in.” If the placeholder promises N digits, cap at exactly N. If you want longer formats, reflect that in the placeholder too.

code field: see “OTP code field” section above.

Pattern: for any OTP-style field where valid input is a narrow format (phone, digits, PIN), apply the filter at both input (prevents typing garbage) AND submit (catches paste edge cases and format mismatches). Don’t rely on backend to reject — user never sees that fast.

Where this lives

Implemented as shared helpers (validateOtpValue, normalizeOtpValue, validateOtpCode, otpDetectValueType) in:

  • resources/assets/scripts/frontend/general.js (desktop)
  • resources/assets/scripts/mobile/app.js (mobile)

Copy these helpers when adding OTP flows to other forms (customer portal, register, password-reset, etc.). Do NOT skip normalization — “works in dev” with raw masked phone will silently fail when the external API rejects the format.