Перейти к содержанию

Zustand-сторы: полный код и описание

Уровень: L3 (deep-dive) | Вверх: README.md | Раздел: ../README.md

authStore

Файл: frontend/src/stores/auth.ts (85 строк)

State shape

interface AuthState {
  accessToken: string | null;   // JWT access token
  refreshToken: string | null;  // JWT refresh token
  user: User | null;            // Профиль пользователя (id, name, email, avatar_media_id)
  isAuthenticated: boolean;     // Флаг авторизации (true если есть accessToken)
}

Actions

Метод Назначение
setTokens(access, refresh?) Сохраняет токены в localStorage и обновляет состояние
setUser(user \| null) Сохраняет/удаляет профиль в localStorage
logout() Очищает все данные: localStorage, состояние, IndexedDB (clearLocalData), кэш ленты
hydrate() Восстанавливает состояние из localStorage при запуске приложения

Полный код

import { create } from 'zustand';
import type { User } from '../types/api';
import { clearLocalData } from '../offline/services/localData';
import { clearFeedSessionCache } from '../utils/feedSessionCache';

const TOKEN_KEY = 'plechiki_access_token';
const REFRESH_KEY = 'plechiki_refresh_token';
const USER_KEY = 'plechiki_user';

interface AuthState {
  accessToken: string | null;
  refreshToken: string | null;
  user: User | null;
  isAuthenticated: boolean;
  setTokens: (access: string, refresh?: string) => void;
  setUser: (user: User | null) => void;
  logout: () => Promise<void>;
  hydrate: () => void;
}

export const useAuthStore = create<AuthState>((set) => ({
  accessToken: null,
  refreshToken: null,
  user: null,
  isAuthenticated: false,

  // Сохраняет токены в localStorage и обновляет флаг isAuthenticated.
  // refresh токен опционален — при refresh-запросе сервер может не возвращать новый refresh.
  setTokens: (access, refresh) => {
    localStorage.setItem(TOKEN_KEY, access);
    if (refresh) localStorage.setItem(REFRESH_KEY, refresh);
    set({
      accessToken: access,
      refreshToken: refresh ?? null,
      isAuthenticated: true,
    });
  },

  // Сохраняет профиль пользователя. При null — удаляет из localStorage.
  setUser: (user) => {
    if (user) {
      localStorage.setItem(USER_KEY, JSON.stringify(user));
    } else {
      localStorage.removeItem(USER_KEY);
    }
    set({ user });
  },

  // Полная очистка при выходе: localStorage, Zustand state, IndexedDB, кэш ленты.
  // clearLocalData() — async, очищает все таблицы Dexie (items, outfits, media, sync).
  // clearFeedSessionCache() — синхронная очистка sessionStorage кэша ленты.
  logout: async () => {
    localStorage.removeItem(TOKEN_KEY);
    localStorage.removeItem(REFRESH_KEY);
    localStorage.removeItem(USER_KEY);
    set({
      accessToken: null,
      refreshToken: null,
      user: null,
      isAuthenticated: false,
    });
    try {
      await clearLocalData();
    } catch (error) {
      console.warn('[Auth] Failed to clear local offline data on logout', error);
    }
    clearFeedSessionCache();
  },

  // Гидрация при старте приложения (вызывается в main.tsx).
  // Восстанавливает токены и профиль из localStorage.
  // JSON.parse обёрнут в try/catch для защиты от повреждённых данных.
  hydrate: () => {
    const accessToken = localStorage.getItem(TOKEN_KEY);
    const refreshToken = localStorage.getItem(REFRESH_KEY);
    const userJson = localStorage.getItem(USER_KEY);

    let user: User | null = null;
    if (userJson) {
      try {
        user = JSON.parse(userJson);
      } catch {
        localStorage.removeItem(USER_KEY);
      }
    }

    set({
      accessToken,
      refreshToken,
      user,
      isAuthenticated: !!accessToken,
    });
  },
}));

Персистентность

Стор не использует middleware persist из Zustand. Вместо этого каждый setter вручную записывает данные в localStorage. Это даёт контроль над тем, что именно персистируется (токены и user, но не isAuthenticated), и позволяет вызывать hydrate() один раз синхронно в main.tsx до рендера.

Подписчики

  • apiClient (axios interceptor) — читает accessToken для инъекции Bearer-токена
  • ProtectedRoute — читает isAuthenticated для редиректа
  • ProfileAvatarButton — читает user для отображения аватара
  • ProfilePage, SettingsPage — читают user для отображения и редактирования

outfitBuilderStore

Файл: frontend/src/stores/outfitBuilder.ts (70 строк)

State shape

interface BuilderItem {
  id: string;           // ID вещи
  title?: string;       // Название вещи
  media_id?: string;    // ID медиа для отображения
  media_url?: string;   // URL медиа
}

interface BuilderSlotState {
  slot: Slot;           // Справочный слот (id, name, order_index, min_layers)
  item?: BuilderItem;   // Выбранная вещь (undefined если слот пуст)
}

interface OutfitBuilderStore {
  slots: BuilderSlotState[];  // Массив слотов с вещами
  title: string;              // Заголовок создаваемого образа
  isSelecting: boolean;       // Флаг: пользователь выбирает вещь для слота
}

Actions

Метод Назначение
initSlots(slots) Инициализирует слоты из справочника (фильтрует min_layers > 0, сортирует по order_index)
setTitle(title) Устанавливает заголовок образа
startSelecting() Устанавливает флаг isSelecting (для навигации на ItemSelectPage)
setSlotItem(slotId, item) Привязывает выбранную вещь к слоту по ID
clearSlot(slotId) Удаляет вещь из слота
clearAll() Сбрасывает все слоты и заголовок

Полный код

import { create } from 'zustand';
import type { Slot } from '../types/api';

export interface BuilderItem {
  id: string;
  title?: string;
  media_id?: string;
  media_url?: string;
}

export interface BuilderSlotState {
  slot: Slot;
  item?: BuilderItem;
}

interface OutfitBuilderStore {
  slots: BuilderSlotState[];
  title: string;
  isSelecting: boolean;
  initSlots: (slots: Slot[]) => void;
  setTitle: (title: string) => void;
  startSelecting: () => void;
  setSlotItem: (slotId: number, item: BuilderItem) => void;
  clearSlot: (slotId: number) => void;
  clearAll: () => void;
}

export const useOutfitBuilderStore = create<OutfitBuilderStore>((set, get) => ({
  slots: [],
  title: '',
  isSelecting: false,

  // Инициализация слотов из справочника. Фильтрует слоты с min_layers > 0
  // (только обязательные слоты). Сортирует по order_index для корректного порядка.
  // Если isSelecting === true, просто сбрасывает флаг (возврат с ItemSelectPage).
  initSlots: (slots) => {
    const { isSelecting } = get();
    if (isSelecting) {
      set({ isSelecting: false });
      return;
    }
    set({
      slots: slots
        .filter((s) => s.min_layers > 0)
        .sort((a, b) => a.order_index - b.order_index)
        .map((slot) => ({ slot })),
    });
  },

  // Устанавливает флаг для навигации на страницу выбора вещи.
  // При возврате initSlots видит isSelecting=true и не пересоздаёт слоты.
  startSelecting: () => set({ isSelecting: true }),

  setTitle: (title) => set({ title }),

  // Иммутабельное обновление: map создаёт новый массив,
  // заменяя только слот с совпавшим id.
  setSlotItem: (slotId, item) =>
    set((state) => ({
      slots: state.slots.map((ss) =>
        ss.slot.id === slotId ? { ...ss, item } : ss,
      ),
    })),

  clearSlot: (slotId) =>
    set((state) => ({
      slots: state.slots.map((ss) =>
        ss.slot.id === slotId ? { ...ss, item: undefined } : ss,
      ),
    })),

  // Полный сброс: очищает все вещи из слотов и обнуляет заголовок.
  // Сами слоты (структура) сохраняются.
  clearAll: () =>
    set((state) => ({
      title: '',
      slots: state.slots.map((ss) => ({ ...ss, item: undefined })),
    })),
}));

Персистентность

Стор не персистируется. Состояние конструктора существует только в рамках текущей сессии конструирования (от перехода на /outfits/build до создания образа или ухода). При размонтировании страницы данные не сохраняются.

Паттерн навигации с isSelecting

Флаг isSelecting решает проблему навигации: при переходе OutfitBuilderPage → ItemSelectPage → OutfitBuilderPage страница конструктора ре-монтируется, что вызывает initSlots. Без флага слоты были бы пересозданы и потерялись бы выбранные вещи. С флагом — initSlots видит isSelecting: true, сбрасывает его и не трогает слоты.