diff --git a/alembic/versions/20261012_0055_survey_responses.py b/alembic/versions/20261012_0055_survey_responses.py
new file mode 100644
index 0000000..7a50d26
--- /dev/null
+++ b/alembic/versions/20261012_0055_survey_responses.py
@@ -0,0 +1,46 @@
+"""Odpowiedzi na jednorazowa ankiete in-app (skad uzytkownicy, czego uzywaja, za co zaplaca).
+
+Jedna odpowiedz na urzadzenie i ankiete, tresc odpowiedzi w JSONB (pytania zyja w apce,
+backend pilnuje tylko dozwolonych kluczy). Patrz app/api/survey.py.
+
+Revision ID: 0055_survey_responses
+Revises: 0054_hidden_sites
+Create Date: 2026-10-12
+"""
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+
+revision = "0055_survey_responses"
+down_revision: str | None = "0054_hidden_sites"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "survey_responses",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("survey_id", sa.String(32), nullable=False),
+ sa.Column("device_id", sa.String(64), nullable=False),
+ sa.Column("answers", JSONB, nullable=False),
+ sa.Column("free_text", sa.Text, nullable=True),
+ sa.Column("app_version", sa.String(32), nullable=True),
+ sa.Column(
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.Column(
+ "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ sa.UniqueConstraint("survey_id", "device_id", name="uq_survey_responses_survey_device"),
+ )
+ op.create_index(
+ "ix_survey_responses_survey_created", "survey_responses", ["survey_id", "created_at"]
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_survey_responses_survey_created", table_name="survey_responses")
+ op.drop_table("survey_responses")
diff --git a/app/api/survey.py b/app/api/survey.py
new file mode 100644
index 0000000..83a2df0
--- /dev/null
+++ b/app/api/survey.py
@@ -0,0 +1,205 @@
+"""Jednorazowa ankieta in-app: skad uzytkownicy, czego uzywaja, za co by zaplacili.
+
+Pytania i etykiety sa w apce (mobile/src/components/SurveyModal.tsx, ten sam SURVEY_ID),
+backend trzyma tylko liste dozwolonych kluczy i limity rozmiaru, zeby nie przyjmowac smieci.
+
+- Jedna odpowiedz na urzadzenie (UNIQUE survey_id + device_id), ponowne wyslanie nadpisuje.
+- Bramka: pytamy tylko urzadzenia, ktore apki realnie uzywaja, czyli maja co najmniej
+ SURVEY_MIN_SCENES wpisow w scene_play_progress. Klucz glowny tej tabeli to
+ (device_id, scene_id), wiec liczenie z LIMIT to krotki skan indeksu. GET /survey/current
+ wola kazdy launch, dopoki apka nie zapisze lokalnie "odpowiedziane/pominiete".
+- Podsumowanie (GET /survey/{id}/summary) liczy w Pythonie po wszystkich wierszach ankiety;
+ przy tysiacach odpowiedzi to ulamek sekundy, bez osobnych agregatow w SQL.
+"""
+from __future__ import annotations
+
+import uuid
+from collections import Counter, defaultdict
+from datetime import datetime
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel, Field
+from sqlalchemy import func, select
+from sqlalchemy.dialects.postgresql import insert as pg_insert
+from sqlalchemy.orm import Session
+
+from app.api.device import LEGACY_DEVICE, get_device_id
+from app.auth import require_api_key
+from app.db import get_session
+from app.models.play_progress import ScenePlayProgress
+from app.models.survey_response import SurveyResponse
+
+router = APIRouter(prefix="/survey", tags=["survey"], dependencies=[Depends(require_api_key)])
+
+CURRENT_SURVEY_ID = "2026-10"
+SURVEY_MIN_SCENES = 10
+
+# Klucze pytan dozwolone w `answers` (wartosci: string albo lista stringow).
+# `*_text` to wolne dopowiedzenie do opcji "inne" w pytaniach jednokrotnego wyboru.
+_ALLOWED_KEYS: frozenset[str] = frozenset(
+ {
+ "source",
+ "source_text",
+ "use",
+ "pain",
+ "pain_text",
+ "pay_for",
+ "pay_how",
+ "price",
+ }
+)
+_MAX_VALUE_LEN = 200
+_MAX_LIST_ITEMS = 12
+
+
+class SurveyStatusOut(BaseModel):
+ id: str
+ eligible: bool
+ answered: bool
+
+
+class SurveySubmitIn(BaseModel):
+ answers: dict[str, str | list[str]]
+ free_text: str | None = Field(default=None, max_length=2000)
+ app_version: str | None = Field(default=None, max_length=32)
+
+
+class SurveySummaryOut(BaseModel):
+ survey_id: str
+ total: int
+ first_at: datetime | None
+ last_at: datetime | None
+ counts: dict[str, dict[str, int]]
+ by_app_version: dict[str, int]
+ texts: dict[str, list[str]]
+
+
+def _played_scenes(session: Session, device_id: str, cap: int) -> int:
+ sub = (
+ select(ScenePlayProgress.scene_id)
+ .where(ScenePlayProgress.device_id == device_id)
+ .limit(cap)
+ .subquery()
+ )
+ return int(session.scalar(select(func.count()).select_from(sub)) or 0)
+
+
+def _answered(session: Session, survey_id: str, device_id: str) -> bool:
+ return (
+ session.scalar(
+ select(SurveyResponse.id).where(
+ SurveyResponse.survey_id == survey_id,
+ SurveyResponse.device_id == device_id,
+ )
+ )
+ is not None
+ )
+
+
+@router.get("/current", response_model=SurveyStatusOut)
+def current_survey(
+ session: Annotated[Session, Depends(get_session)],
+ device_id: Annotated[str, Depends(get_device_id)],
+) -> SurveyStatusOut:
+ if device_id == LEGACY_DEVICE:
+ return SurveyStatusOut(id=CURRENT_SURVEY_ID, eligible=False, answered=False)
+ answered = _answered(session, CURRENT_SURVEY_ID, device_id)
+ eligible = answered or _played_scenes(session, device_id, SURVEY_MIN_SCENES) >= SURVEY_MIN_SCENES
+ return SurveyStatusOut(id=CURRENT_SURVEY_ID, eligible=eligible, answered=answered)
+
+
+def _clean_answers(raw: dict[str, str | list[str]]) -> dict[str, Any]:
+ out: dict[str, Any] = {}
+ for key, value in raw.items():
+ if key not in _ALLOWED_KEYS:
+ raise HTTPException(status_code=422, detail=f"unknown question: {key}")
+ if isinstance(value, str):
+ v = value.strip()
+ if len(v) > _MAX_VALUE_LEN:
+ raise HTTPException(status_code=422, detail=f"value too long: {key}")
+ if v:
+ out[key] = v
+ else:
+ items = [s.strip() for s in value if isinstance(s, str) and s.strip()]
+ if len(items) > _MAX_LIST_ITEMS or any(len(s) > _MAX_VALUE_LEN for s in items):
+ raise HTTPException(status_code=422, detail=f"list too long: {key}")
+ if items:
+ out[key] = items
+ if not out:
+ raise HTTPException(status_code=422, detail="empty answers")
+ return out
+
+
+@router.post("/{survey_id}", status_code=status.HTTP_201_CREATED)
+def submit_survey(
+ survey_id: str,
+ payload: SurveySubmitIn,
+ session: Annotated[Session, Depends(get_session)],
+ device_id: Annotated[str, Depends(get_device_id)],
+) -> dict[str, str]:
+ if survey_id != CURRENT_SURVEY_ID:
+ raise HTTPException(status_code=404, detail="no such survey")
+ if device_id == LEGACY_DEVICE:
+ raise HTTPException(status_code=400, detail="device id required")
+ answers = _clean_answers(payload.answers)
+ free_text = (payload.free_text or "").strip() or None
+ stmt = pg_insert(SurveyResponse).values(
+ id=uuid.uuid4(),
+ survey_id=survey_id,
+ device_id=device_id,
+ answers=answers,
+ free_text=free_text,
+ app_version=payload.app_version,
+ )
+ stmt = stmt.on_conflict_do_update(
+ constraint="uq_survey_responses_survey_device",
+ set_={
+ "answers": stmt.excluded.answers,
+ "free_text": stmt.excluded.free_text,
+ "app_version": stmt.excluded.app_version,
+ "updated_at": func.now(),
+ },
+ )
+ session.execute(stmt)
+ session.commit()
+ return {"status": "ok"}
+
+
+@router.get("/{survey_id}/summary", response_model=SurveySummaryOut)
+def survey_summary(
+ survey_id: str,
+ session: Annotated[Session, Depends(get_session)],
+) -> SurveySummaryOut:
+ """Podsumowanie dla admina: liczniki per pytanie i opcja, wersje apki, wolne teksty."""
+ rows = session.scalars(
+ select(SurveyResponse)
+ .where(SurveyResponse.survey_id == survey_id)
+ .order_by(SurveyResponse.created_at)
+ ).all()
+ counts: dict[str, Counter[str]] = defaultdict(Counter)
+ versions: Counter[str] = Counter()
+ texts: dict[str, list[str]] = defaultdict(list)
+ for r in rows:
+ versions[r.app_version or "?"] += 1
+ if r.free_text:
+ texts["free_text"].append(r.free_text)
+ for key, value in (r.answers or {}).items():
+ if key.endswith("_text"):
+ if isinstance(value, str):
+ texts[key].append(value)
+ continue
+ if isinstance(value, list):
+ for v in value:
+ counts[key][str(v)] += 1
+ else:
+ counts[key][str(value)] += 1
+ return SurveySummaryOut(
+ survey_id=survey_id,
+ total=len(rows),
+ first_at=rows[0].created_at if rows else None,
+ last_at=rows[-1].created_at if rows else None,
+ counts={k: dict(c.most_common()) for k, c in counts.items()},
+ by_app_version=dict(versions.most_common()),
+ texts={k: v[-300:] for k, v in texts.items()},
+ )
diff --git a/app/main.py b/app/main.py
index 06cabb1..34c76b1 100644
--- a/app/main.py
+++ b/app/main.py
@@ -12,6 +12,7 @@ from app.api.admin_html import mount_static
from app.api.admin_html import router as admin_html_router
from app.api.blacklist import router as blacklist_router
from app.api.bug_reports import router as bug_reports_router
+from app.api.survey import router as survey_router
from app.api.clips import router as clips_router
from app.api.expo_updates import router as expo_updates_router
from app.api.favorites import router as favorites_router
@@ -83,6 +84,7 @@ app.include_router(taxonomies_router)
app.include_router(favorites_router)
app.include_router(blacklist_router)
app.include_router(bug_reports_router)
+app.include_router(survey_router)
app.include_router(clips_router)
app.include_router(saved_searches_router)
app.include_router(expo_updates_router)
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 83a549d..c4c38d3 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -29,6 +29,7 @@ from app.models.phash_backfill_queue import PhashBackfillQueue
from app.models.phash_blacklist import PhashBlacklist
from app.models.saved_search import SavedSearch
from app.models.source_stats import SourceStats
+from app.models.survey_response import SurveyResponse
from app.models.scene import (
Scene,
SceneExternalRef,
@@ -85,6 +86,7 @@ __all__ = [
"Source",
"Studio",
"StudioAlias",
+ "SurveyResponse",
"StudioExternalRef",
"Tag",
"TagVerticalCount",
diff --git a/app/models/survey_response.py b/app/models/survey_response.py
new file mode 100644
index 0000000..27ac86a
--- /dev/null
+++ b/app/models/survey_response.py
@@ -0,0 +1,37 @@
+"""Odpowiedzi na jednorazowa ankiete in-app (pytania sa w apce, patrz app/api/survey.py).
+
+Jedna odpowiedz na urzadzenie i ankiete (UNIQUE survey_id + device_id, ponowne wyslanie
+nadpisuje). `answers` to JSON {klucz_pytania: wartosc | [wartosci]}, bez danych osobowych.
+"""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint, func
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.base import Base
+
+
+class SurveyResponse(Base):
+ __tablename__ = "survey_responses"
+ __table_args__ = (
+ UniqueConstraint("survey_id", "device_id", name="uq_survey_responses_survey_device"),
+ Index("ix_survey_responses_survey_created", "survey_id", "created_at"),
+ )
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ survey_id: Mapped[str] = mapped_column(String(32), nullable=False)
+ device_id: Mapped[str] = mapped_column(String(64), nullable=False)
+ answers: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
+ free_text: Mapped[str | None] = mapped_column(Text, nullable=True)
+ app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
+ )
diff --git a/mobile/App.tsx b/mobile/App.tsx
index 68fe1cd..a92966c 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -29,6 +29,7 @@ import { ClientProvider } from './src/ClientContext';
import { PreferencesProvider } from './src/PreferencesContext';
import { SceneActionsProvider } from './src/SceneActionsContext';
import { OnboardingModal } from './src/components/OnboardingModal';
+import { SurveyModal } from './src/components/SurveyModal';
import { WhatsNewModal } from './src/components/WhatsNewModal';
import { ErrorBoundary } from './src/ErrorBoundary';
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
@@ -514,6 +515,7 @@ export default function App() {
}}
/>
+
diff --git a/mobile/src/api.ts b/mobile/src/api.ts
index 4581d54..c58c332 100644
--- a/mobile/src/api.ts
+++ b/mobile/src/api.ts
@@ -584,6 +584,26 @@ export class GoonClient {
});
}
+ // Jednorazowa ankieta in-app (SurveyModal). Backend decyduje, czy pytac to urzadzenie
+ // (bramka po obejrzanych scenach) i czy juz odpowiedzialo.
+ async getCurrentSurvey(): Promise<{ id: string; eligible: boolean; answered: boolean }> {
+ return this.request('/survey/current');
+ }
+
+ async submitSurvey(
+ surveyId: string,
+ payload: {
+ answers: Record;
+ free_text?: string | null;
+ app_version?: string | null;
+ },
+ ): Promise<{ status: string }> {
+ return this.request(`/survey/${encodeURIComponent(surveyId)}`, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+ }
+
async listMyBugReports(): Promise<{
items: {
id: string;
diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts
index 86e20ac..d06709e 100644
--- a/mobile/src/changelog.ts
+++ b/mobile/src/changelog.ts
@@ -35,6 +35,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ id: '2026-10-13a',
+ date: 'October 2026',
+ items: [
+ 'A one-minute survey will pop up once, after this update. Six questions about how you found Goon, what you use and what you would pay for. Your answers decide what gets built next. You can skip it.',
+ ],
+ },
{
id: '2026-10-12b',
date: 'October 2026',
diff --git a/mobile/src/components/SurveyModal.tsx b/mobile/src/components/SurveyModal.tsx
new file mode 100644
index 0000000..7185806
--- /dev/null
+++ b/mobile/src/components/SurveyModal.tsx
@@ -0,0 +1,464 @@
+/**
+ * Jednorazowa ankieta (6 pytan + jedno pole wolne), pomijalna, raz na urzadzenie.
+ *
+ * Kiedy: po tutorialu, tylko dla urzadzen, ktore apki realnie uzywaja (backend liczy
+ * obejrzane sceny, GET /survey/current), nigdy razem z popupem "What's new" (czeka na jego
+ * zamkniecie przez popupBus). "Not now" i wyslanie zapisuja flage lokalnie, wiec ankieta
+ * nie wraca. Blad sieci przy wysylce nie zapisuje flagi, apka sprobuje przy nastepnym
+ * uruchomieniu. Klucze pytan i opcji musza zgadzac sie z app/api/survey.py.
+ */
+import React from 'react';
+import {
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+
+import { useClient } from '../ClientContext';
+import { unseenEntries } from '../changelog';
+import { APP_VERSION } from '../lib/appVersion';
+import { onWhatsNewClosed } from '../lib/popupBus';
+import {
+ getLastSeenChangelog,
+ getOnboardingSeen,
+ getSurveyDone,
+ setSurveyDone,
+} from '../storage';
+import { theme } from '../theme';
+
+export const SURVEY_ID = '2026-10';
+
+type Option = { value: string; label: string; text?: boolean };
+type Question = {
+ key: string;
+ title: string;
+ hint?: string;
+ multi?: boolean;
+ /** Maksymalna liczba zaznaczen przy multi (wymusza priorytety). */
+ max?: number;
+ options: Option[];
+};
+
+const QUESTIONS: Question[] = [
+ {
+ key: 'source',
+ title: 'How did you find Goon?',
+ options: [
+ { value: 'mobilism', label: 'Mobilism forum' },
+ { value: 'f95zone', label: 'F95zone' },
+ { value: 'stash', label: 'Stash community (forum, GitHub, Reddit)' },
+ { value: 'google', label: 'Google search (a performer or studio page)' },
+ { value: 'telegram', label: 'Telegram' },
+ { value: 'reddit', label: 'Reddit (other)' },
+ { value: 'friend', label: 'A friend' },
+ { value: 'list', label: 'A list of sites or apps (FMHY and similar)' },
+ { value: 'other', label: 'Somewhere else', text: true },
+ ],
+ },
+ {
+ key: 'use',
+ title: 'What do you use most?',
+ hint: 'Pick up to three.',
+ multi: true,
+ max: 3,
+ options: [
+ { value: 'scenes', label: 'Browsing scenes' },
+ { value: 'performers_studios', label: 'Performers and studios' },
+ { value: 'movies', label: 'Movies' },
+ { value: 'edge', label: 'Edge (clips)' },
+ { value: 'jav', label: 'JAV' },
+ { value: 'favorites', label: 'Favorites and new-scene badges' },
+ { value: 'saved_searches', label: 'Saved searches' },
+ { value: 'cast_tv', label: 'Chromecast or Android TV' },
+ ],
+ },
+ {
+ key: 'pain',
+ title: 'What annoys you most?',
+ options: [
+ { value: 'playback_fails', label: 'Videos that fail to play on some sites' },
+ { value: 'slow', label: 'Slow loading' },
+ { value: 'missing_sites', label: 'Sites that are missing' },
+ { value: 'missing_performers', label: 'Performers or scenes that are missing' },
+ { value: 'missing_orientation', label: 'Too little gay or trans content, or too much of it' },
+ { value: 'updates', label: 'Updating the app' },
+ { value: 'nothing', label: 'Nothing, it is fine' },
+ { value: 'other', label: 'Something else', text: true },
+ ],
+ },
+ {
+ key: 'pay_for',
+ title: 'What would you pay a few dollars a year for?',
+ hint: 'Pick any.',
+ multi: true,
+ options: [
+ { value: 'notifications', label: 'Alerts when favorite performers have new scenes' },
+ { value: 'instant_play', label: 'Instant play and video previews' },
+ { value: 'cast_tv', label: 'Chromecast and Android TV' },
+ { value: 'early_access', label: 'Early access and a vote on what gets built' },
+ { value: 'community', label: 'A private community' },
+ { value: 'nothing', label: 'Nothing' },
+ { value: 'donate', label: 'I would rather donate' },
+ ],
+ },
+ {
+ key: 'pay_how',
+ title: 'If you paid, how?',
+ options: [
+ { value: 'have_crypto', label: 'Crypto, I already have some' },
+ { value: 'crypto_if_easy', label: 'Crypto, if I can buy it right in the app' },
+ { value: 'card_only', label: 'Card only' },
+ { value: 'wont_pay', label: 'I would not pay' },
+ ],
+ },
+ {
+ key: 'price',
+ title: 'A fair price per year?',
+ options: [
+ { value: '0', label: 'Free only' },
+ { value: '5', label: '$5' },
+ { value: '10', label: '$10' },
+ { value: '20', label: '$20' },
+ { value: '30', label: '$30 or more' },
+ ],
+ },
+];
+
+// Ostatni krok to pole wolne, stad +1.
+const TOTAL_STEPS = QUESTIONS.length + 1;
+
+export function SurveyModal() {
+ const client = useClient();
+ const [visible, setVisible] = React.useState(false);
+ const [step, setStep] = React.useState(0);
+ const [answers, setAnswers] = React.useState>({});
+ const [otherText, setOtherText] = React.useState>({});
+ const [freeText, setFreeText] = React.useState('');
+ const [sending, setSending] = React.useState(false);
+ const [sent, setSent] = React.useState(false);
+ const [error, setError] = React.useState(null);
+
+ React.useEffect(() => {
+ let cancelled = false;
+ let unsub: (() => void) | null = null;
+ let timer: ReturnType | null = null;
+ (async () => {
+ try {
+ if (!(await getOnboardingSeen())) return;
+ if (await getSurveyDone(SURVEY_ID)) return;
+ const cur = await client.getCurrentSurvey();
+ if (cancelled || cur.id !== SURVEY_ID || !cur.eligible) return;
+ if (cur.answered) {
+ setSurveyDone(SURVEY_ID).catch(() => {});
+ return;
+ }
+ const show = () => {
+ if (!cancelled) setVisible(true);
+ };
+ const lastSeen = await getLastSeenChangelog();
+ if (unseenEntries(lastSeen).length > 0) {
+ // "What's new" wyskoczy przy tym uruchomieniu, ankieta dopiero po nim.
+ unsub = onWhatsNewClosed(() => {
+ timer = setTimeout(show, 700);
+ });
+ } else {
+ timer = setTimeout(show, 1500);
+ }
+ } catch {
+ // brak sieci / SecureStore: po prostu nie pytamy
+ }
+ })();
+ return () => {
+ cancelled = true;
+ if (unsub) unsub();
+ if (timer) clearTimeout(timer);
+ };
+ }, [client]);
+
+ const question = step < QUESTIONS.length ? QUESTIONS[step] : null;
+ const selected = question ? answers[question.key] ?? [] : [];
+
+ const toggle = React.useCallback(
+ (q: Question, value: string) => {
+ setAnswers((prev) => {
+ const cur = prev[q.key] ?? [];
+ if (!q.multi) return { ...prev, [q.key]: [value] };
+ if (cur.includes(value)) return { ...prev, [q.key]: cur.filter((v) => v !== value) };
+ if (q.max && cur.length >= q.max) return prev;
+ return { ...prev, [q.key]: [...cur, value] };
+ });
+ },
+ [],
+ );
+
+ const canAdvance = question ? selected.length > 0 : true;
+
+ const dismiss = React.useCallback(() => {
+ setVisible(false);
+ setSurveyDone(SURVEY_ID).catch(() => {});
+ }, []);
+
+ const submit = React.useCallback(async () => {
+ if (sending) return;
+ setSending(true);
+ setError(null);
+ const payload: Record = {};
+ for (const q of QUESTIONS) {
+ const vals = answers[q.key] ?? [];
+ if (vals.length === 0) continue;
+ payload[q.key] = q.multi ? vals : vals[0];
+ const txt = (otherText[q.key] ?? '').trim();
+ if (!q.multi && vals[0] === 'other' && txt) payload[`${q.key}_text`] = txt;
+ }
+ try {
+ await client.submitSurvey(SURVEY_ID, {
+ answers: payload,
+ free_text: freeText.trim() || null,
+ app_version: APP_VERSION,
+ });
+ await setSurveyDone(SURVEY_ID).catch(() => {});
+ setSent(true);
+ setTimeout(() => setVisible(false), 1400);
+ } catch {
+ setError('Could not send. Check your connection and try again.');
+ } finally {
+ setSending(false);
+ }
+ }, [answers, client, freeText, otherText, sending]);
+
+ if (!visible) return null;
+
+ const showOtherInput =
+ question && !question.multi && selected[0] === 'other' && question.options.some((o) => o.text);
+
+ return (
+
+
+
+ {sent ? (
+
+ Thank you
+ Your answers decide what gets built next.
+
+ ) : (
+ <>
+
+ {step === 0 ? 'One-minute survey' : 'Survey'}
+
+ {step + 1}/{TOTAL_STEPS}
+
+
+ {step === 0 ? (
+
+ Six quick questions. They decide what we build next and how Goon stays
+ online. No account, no tracking, just your answers.
+
+ ) : null}
+
+
+ {question ? (
+ <>
+ {question.title}
+ {question.hint ? {question.hint} : null}
+ {question.options.map((o) => {
+ const on = selected.includes(o.value);
+ return (
+ toggle(question, o.value)}
+ style={({ pressed }) => [
+ styles.option,
+ on && styles.optionOn,
+ pressed && styles.optionPressed,
+ ]}
+ accessibilityRole={question.multi ? 'checkbox' : 'radio'}
+ accessibilityState={{ checked: on }}
+ >
+
+ {on ? ✓ : null}
+
+
+ {o.label}
+
+
+ );
+ })}
+ {showOtherInput ? (
+
+ setOtherText((prev) => ({ ...prev, [question.key]: t }))
+ }
+ placeholder="Tell us where or what (optional)"
+ placeholderTextColor={theme.mutedDim}
+ style={styles.input}
+ maxLength={200}
+ />
+ ) : null}
+ >
+ ) : (
+ <>
+ One thing you would add or change?
+ Optional.
+
+ {error ? {error} : null}
+ >
+ )}
+
+
+
+ {step === 0 ? (
+
+ Not now
+
+ ) : (
+ setStep((s) => s - 1)}>
+ Back
+
+ )}
+ {question ? (
+ setStep((s) => s + 1)}
+ >
+ Next
+
+ ) : (
+
+ {sending ? 'Sending' : 'Send'}
+
+ )}
+
+ >
+ )}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.7)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 20,
+ },
+ card: {
+ width: '100%',
+ maxHeight: '88%',
+ backgroundColor: theme.bgElevated,
+ borderRadius: 16,
+ borderColor: theme.border,
+ borderWidth: 1,
+ padding: 20,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'baseline',
+ justifyContent: 'space-between',
+ marginBottom: 8,
+ },
+ title: { color: theme.fg, fontSize: 20, fontWeight: '800' },
+ progress: { color: theme.mutedDim, fontSize: 13, fontWeight: '700' },
+ intro: { color: theme.muted, fontSize: 14, lineHeight: 20, marginBottom: 12 },
+ scroll: { flexGrow: 0 },
+ question: { color: theme.fg, fontSize: 17, fontWeight: '700', marginBottom: 4 },
+ hint: { color: theme.muted, fontSize: 13, lineHeight: 18, marginBottom: 10 },
+ option: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ paddingVertical: 11,
+ paddingHorizontal: 12,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: theme.border,
+ backgroundColor: theme.card,
+ marginBottom: 8,
+ },
+ optionOn: { borderColor: theme.accent, backgroundColor: theme.bg },
+ optionPressed: { opacity: 0.8 },
+ mark: {
+ width: 22,
+ height: 22,
+ borderWidth: 2,
+ borderColor: theme.borderStrong,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ markRound: { borderRadius: 11 },
+ markSquare: { borderRadius: 5 },
+ markOn: { borderColor: theme.accent, backgroundColor: theme.accent },
+ markTick: { color: theme.fg, fontSize: 13, fontWeight: '900', lineHeight: 16 },
+ optionText: { color: theme.fg, fontSize: 15, lineHeight: 20, flex: 1 },
+ optionTextOn: { fontWeight: '700' },
+ input: {
+ marginTop: 4,
+ marginBottom: 8,
+ color: theme.fg,
+ fontSize: 15,
+ backgroundColor: theme.bg,
+ borderWidth: 1,
+ borderColor: theme.border,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ },
+ inputTall: { minHeight: 110 },
+ error: { color: theme.bad, fontSize: 13, marginTop: 6 },
+ buttons: { flexDirection: 'row', gap: 10, marginTop: 10 },
+ btn: {
+ flex: 1,
+ backgroundColor: theme.accent,
+ borderRadius: 10,
+ paddingVertical: 12,
+ alignItems: 'center',
+ },
+ btnDisabled: { opacity: 0.45 },
+ btnText: { color: theme.fg, fontSize: 15, fontWeight: '700' },
+ btnGhost: {
+ flex: 1,
+ borderRadius: 10,
+ paddingVertical: 12,
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: theme.border,
+ },
+ btnGhostText: { color: theme.muted, fontSize: 15, fontWeight: '700' },
+ thanks: { paddingVertical: 12 },
+});
diff --git a/mobile/src/components/WhatsNewModal.tsx b/mobile/src/components/WhatsNewModal.tsx
index a86c638..b442884 100644
--- a/mobile/src/components/WhatsNewModal.tsx
+++ b/mobile/src/components/WhatsNewModal.tsx
@@ -12,6 +12,7 @@ import {
unseenEntries,
type ChangelogEntry,
} from '../changelog';
+import { emitWhatsNewClosed } from '../lib/popupBus';
import { getLastSeenChangelog, getOnboardingSeen, setLastSeenChangelog } from '../storage';
import { theme } from '../theme';
@@ -45,6 +46,8 @@ export function WhatsNewModal() {
const dismiss = React.useCallback(() => {
setVisible(false);
if (NEWEST_CHANGELOG_ID) setLastSeenChangelog(NEWEST_CHANGELOG_ID).catch(() => {});
+ // SurveyModal czeka na to zamkniecie, zeby nie wyskoczyc na "What's new".
+ emitWhatsNewClosed();
}, []);
// Nagłówek miesiąca RAZ na grupę, nie przy każdym wpisie. Wcześniej 17 sierpniowych
diff --git a/mobile/src/lib/popupBus.ts b/mobile/src/lib/popupBus.ts
new file mode 100644
index 0000000..e3fa9ab
--- /dev/null
+++ b/mobile/src/lib/popupBus.ts
@@ -0,0 +1,18 @@
+/**
+ * Mini szyna zdarzen miedzy popupami montowanymi nad nawigacja (App.tsx).
+ * WhatsNewModal po zamknieciu woła `emitWhatsNewClosed()`, SurveyModal czeka na to,
+ * zeby dwa popupy nie wyskoczyly jeden na drugim przy tym samym uruchomieniu.
+ * Ten sam wzorzec co onboardingBus: zero zaleznosci, listenerzy w module scope.
+ */
+type Listener = () => void;
+
+const whatsNewClosed = new Set();
+
+export function onWhatsNewClosed(cb: Listener): () => void {
+ whatsNewClosed.add(cb);
+ return () => whatsNewClosed.delete(cb);
+}
+
+export function emitWhatsNewClosed(): void {
+ whatsNewClosed.forEach((cb) => cb());
+}
diff --git a/mobile/src/storage.ts b/mobile/src/storage.ts
index 95eacc5..97ef8df 100644
--- a/mobile/src/storage.ts
+++ b/mobile/src/storage.ts
@@ -63,6 +63,22 @@ export async function setLastSeenChangelog(id: string): Promise {
await SecureStore.setItemAsync(CHANGELOG_SEEN_KEY, id);
}
+// Ankieta in-app: flaga "odpowiedziane albo pominiete" per ankieta (SurveyModal).
+// Klucz z id ankiety, wiec kolejna ankieta (nowy id) sama zapyta od nowa.
+const SURVEY_DONE_PREFIX = 'goon.survey_done.';
+
+export async function getSurveyDone(surveyId: string): Promise {
+ try {
+ return (await SecureStore.getItemAsync(SURVEY_DONE_PREFIX + surveyId)) === '1';
+ } catch {
+ return true; // brak SecureStore: nie meczyc ankieta w kolko
+ }
+}
+
+export async function setSurveyDone(surveyId: string): Promise {
+ await SecureStore.setItemAsync(SURVEY_DONE_PREFIX + surveyId, '1');
+}
+
// Onboarding/tutorial, pokazany raz przy pierwszym odpaleniu (carousel: tabs,
// funkcje, longpressy, gesty playera). Wersjonowany klucz → bump gdy tutorial
// istotnie się zmieni i chcemy pokazać ponownie. Replay z ustawień przez bus.