From 113dd242ac6abe091bfcbffbdbdffde240ae5d43 Mon Sep 17 00:00:00 2001 From: TheGeniusOfEternity Date: Tue, 4 Aug 2026 22:30:54 +0300 Subject: [PATCH 1/4] chore: stop tracking local prompts --- opencode.json | 14 ---- prompts/phase-1-infra.md | 140 ------------------------------------- prompts/phase-2-auth.md | 123 -------------------------------- prompts/phase-3-builder.md | 120 ------------------------------- prompts/phase-4-render.md | 55 --------------- 5 files changed, 452 deletions(-) delete mode 100644 opencode.json delete mode 100644 prompts/phase-1-infra.md delete mode 100644 prompts/phase-2-auth.md delete mode 100644 prompts/phase-3-builder.md delete mode 100644 prompts/phase-4-render.md diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 58a010d..0000000 --- a/opencode.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "figma": { - "type": "local", - "command": ["npx", "-y", "figma-developer-mcp", "--stdio"], - "environment": { - "FIGMA_ACCESS_TOKEN": "{env:FIGMA_ACCESS_TOKEN}" - }, - "enabled": true, - "timeout": 15000 - } - } -} diff --git a/prompts/phase-1-infra.md b/prompts/phase-1-infra.md deleted file mode 100644 index 8559596..0000000 --- a/prompts/phase-1-infra.md +++ /dev/null @@ -1,140 +0,0 @@ -# Phase 1: Infrastructure Setup - -## Goals -1. Очистить проект от существующего Vue-кода -2. Настроить монорепозиторий (npm workspaces) -3. Инициализировать React + Vite + TypeScript в `client/` -4. Инициализировать Express + TypeScript + Prisma в `server/` -5. Настроить PostgreSQL и Prisma migrations -6. Проверить, что всё собирается - -## Steps - -### 1. Очистка -- Удалить `src/` (весь Vue-код) -- Удалить `stats.html`, `langs.html` -- Удалить `public/` (содержимое, кроме favicon по желанию) -- Удалить `dist/` -- Удалить Vue-зависимости из package.json -- Удалить Vue-специфичные конфиги (`vite.config.ts`, `tsconfig.app.json`, `tsconfig.node.json`, `eslint.config.mts`) - -### 2. Root package.json (npm workspaces) -```json -{ - "name": "github-stats", - "private": true, - "scripts": { - "dev": "concurrently \"npm run dev -w client\" \"npm run dev -w server\"", - "build": "npm run build -w client && npm run build -w server" - }, - "devDependencies": { - "concurrently": "^9.1.0", - "typescript": "^5.7.0" - } -} -``` - -### 3. Client (React + Vite) -```bash -cd client -npm create vite@latest . -- --template react-ts -``` - -Установить зависимости: -- react, react-dom -- @gravity-ui/uikit, @gravity-ui/icons -- framer-motion -- zustand - -vite.config.ts — настроить proxy на сервер: -```ts -export default defineConfig({ - plugins: [react()], - server: { - proxy: { - '/api': 'http://localhost:4000' - } - } -}) -``` - -### 4. Server (Express + Prisma) -```bash -mkdir server && cd server -npm init -y -``` - -Установить: -- express, cors, helmet, morgan -- @prisma/client, prisma (dev) -- jsonwebtoken, bcryptjs -- Ручной Yandex OAuth через `fetch` -- zod (валидация) -- dotenv -- tsx, @types/* - -tsconfig.json — extends `../tsconfig.base.json` - -### 5. Prisma schema -Создать `prisma/schema.prisma` и применять изменения отдельными migrations. - -### 6. PostgreSQL -```yaml -version: '3.8' -services: - postgres: - image: postgres:16 - ports: - - '5432:5432' - environment: - POSTGRES_USER: widget_user - POSTGRES_PASSWORD: widget_pass - POSTGRES_DB: widget_db - volumes: - - pgdata:/var/lib/postgresql/data - -volumes: - pgdata: -``` - -### 7. .env -``` -DATABASE_URL=postgresql://widget_user:widget_pass@localhost:5432/widget_db -JWT_SECRET=super-secret-key -YANDEX_CLIENT_ID= -YANDEX_CLIENT_SECRET= -``` - -### 8. Server entry point -```ts -import express from 'express'; -import cors from 'cors'; -import { PrismaClient } from '@prisma/client'; - -const app = express(); -const prisma = new PrismaClient(); - -app.use(cors()); -app.use(express.json()); - -app.get('/api/health', (req, res) => { - res.json({ status: 'ok' }); -}); - -app.listen(4000, () => { - console.log('Server on http://localhost:4000'); -}); -``` - -## Текущее состояние -- React + Vite + TypeScript настроены в `client/`. -- Express + TypeScript + Prisma настроены в `server/`. -- Используются npm workspaces и `package-lock.json`. -- API-функции Vercel используют собранный server bundle. -- Prisma migrations применяются через `npm run prisma:deploy -w server`. - -## Проверка -- [ ] `npm install` — все зависимости установлены -- [ ] `npm run prisma:deploy -w server` — схема применена -- [ ] `npm run dev` — клиент на 5173, сервер на 4000 -- [ ] `curl http://localhost:4000/api/health` → `{"status":"ok"}` diff --git a/prompts/phase-2-auth.md b/prompts/phase-2-auth.md deleted file mode 100644 index 784761c..0000000 --- a/prompts/phase-2-auth.md +++ /dev/null @@ -1,123 +0,0 @@ -# Phase 2: Authentication - -## Goals -- Регистрация и логин по email + password -- Yandex OAuth -- JWT-токены (access + refresh) -- Защита роутов на клиенте - -## Текущее состояние -- Access token хранится в Zustand в памяти. -- Refresh token хранится в HttpOnly cookie. -- API-клиент автоматически добавляет access token и повторяет запрос после refresh. -- Yandex OAuth реализован через `/api/auth/yandex` и callback. - -## Server - -### Зависимости -- `jsonwebtoken` — генерация/верификация JWT -- `bcryptjs` — хеширование паролей -- `zod` — валидация входных данных -- Ручной Yandex OAuth flow через `fetch`, без Passport. - -### Prisma (уже есть) -Модель User готова. Добавить индекс по `yandexId`, если нет. - -### Auth middleware -```ts -// server/src/middleware/auth.ts -import { Request, Response, NextFunction } from 'express'; -import jwt from 'jsonwebtoken'; - -export interface AuthRequest extends Request { - userId?: string; -} - -export const authMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => { - const token = req.headers.authorization?.replace('Bearer ', ''); - if (!token) return res.status(401).json({ error: 'No token' }); - - try { - const payload = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string }; - req.userId = payload.userId; - next(); - } catch { - res.status(401).json({ error: 'Invalid token' }); - } -}; -``` - -### Auth routes (`/api/auth`) - -**POST /register** -- Body: `{ email, password }` -- Validate: email format, password >= 6 chars -- Hash password with bcryptjs -- Create user in DB -- Return JWT - -**POST /login** -- Body: `{ email, password }` -- Find user by email -- Compare password with bcryptjs -- Return JWT - -**GET /yandex** -- Редирект на Yandex OAuth URL -- Query params: `client_id`, `redirect_uri`, `response_type=code` - -**GET /yandex/callback** -- Принять `code` из query -- Обменять на токен (POST https://oauth.yandex.ru/token) -- Получить email/userinfo (GET https://login.yandex.ru/info) -- Найти или создать пользователя по `yandexId` -- Вернуть JWT и редирект на фронт - -**GET /me** (protected) -- Вернуть данные текущего пользователя (`req.userId`) - -### JWT utils -```ts -export const generateToken = (userId: string): string => { - return jwt.sign({ userId }, process.env.JWT_SECRET!, { expiresIn: '7d' }); -}; -``` - -## Client - -### Страницы -- `/login` — форма логина -- `/register` — форма регистрации -- `/dashboard` — защищённый роут (только для авторизованных) - -### Auth store (Zustand) -```ts -interface AuthState { - token: string | null; - user: User | null; - login: (email: string, password: string) => Promise; - register: (email: string, password: string) => Promise; - logout: () => void; - checkAuth: () => Promise; -} -``` - -### API client -```ts -Используется собственный `apiClient` на базе `fetch`: он добавляет Bearer access token, обновляет его через `/auth/refresh` и повторяет исходный запрос. -``` - -### PrivateRoute -`App` проверяет auth status при bootstrap и перенаправляет защищённые `/dashboard` и `/widgets/:id` на `/login`. - -### Yandex OAuth flow -- Кнопка "Войти через Яндекс" → редирект на `/api/auth/yandex` -- После callback — редирект обратно на `/dashboard` с JWT в query или сохранить через redirect - -## Проверка -- [x] Регистрация email + password → access/refresh session -- [x] Логин → access/refresh session -- [x] Невалидный JWT → 401 -- [x] `/api/auth/me` с валидным JWT → данные пользователя -- [x] Yandex OAuth: кнопка → redirect → callback → access token -- [x] `/dashboard` без токена → redirect на `/login` diff --git a/prompts/phase-3-builder.md b/prompts/phase-3-builder.md deleted file mode 100644 index fc72b13..0000000 --- a/prompts/phase-3-builder.md +++ /dev/null @@ -1,120 +0,0 @@ -# Phase 3: Widget Builder - -## Goals -- CRUD для виджетов -- CRUD для блоков внутри виджета -- Drag-and-drop конструктор на клиенте -- Система типов блоков (plugin-based) - -## Текущее состояние -- Сетка ограничена двумя колонками. -- Каждый блок поддерживает размеры `1x1`, `1x2`, `2x1`, `2x2`. -- Блоки создаются по умолчанию как `1x1`. -- Редактор использует обычный CSS Grid и Pointer Events, без `react-grid-layout` и `@dnd-kit`. -- На mobile редактор заменён desktop-only fallback, а кнопка configure скрыта в галерее. - -## Server - -### Routes - -**`/api/widgets` (protected)** -| Method | Path | Описание | -|--------|------|----------| -| GET | /api/widgets | Список виджетов пользователя | -| POST | /api/widgets | Создать виджет | -| GET | /api/widgets/:id | Виджет с блоками | -| PUT | /api/widgets/:id | Обновить (title, width, height, config, public) | -| DELETE | /api/widgets/:id | Удалить | - -**`/api/widgets/:widgetId/blocks` (protected)** -| Method | Path | Описание | -|--------|------|----------| -| POST | /api/widgets/:widgetId/blocks | Добавить блок | -| PUT | /api/blocks/:id | Обновить блок | -| DELETE | /api/blocks/:id | Удалить блок | -| PUT | /api/widgets/:widgetId/blocks/reorder | Пересортировка (body: { blockIds: string[] }) | - -**`/api/widgets/:widgetId/preview` (protected)** -| Method | Path | Описание | -|--------|------|----------| -| POST | /api/widgets/:widgetId/preview | Preview актуальных данных одного блока | - -### Block types registry -На сервере — enum или маппинг type → validation schema: -```ts -export const BLOCK_TYPES = [ - 'text', - 'github-stats', - 'github-langs', - 'leetcode-stats', -] as const; -``` - -Каждый тип имеет `configSchema` (zod) для валидации config. - -### Validation -- Zod schemas для создания и обновления виджетов и блоков -- Проверять, что виджет принадлежит текущему пользователю (сравнение `userId`) - -## Client - -### Dashboard (`/dashboard`) -- Список виджетов пользователя (карточки) -- Кнопка "Создать виджет" → модалка с названием -- Кнопка "Удалить" с подтверждением -- Клик по виджету → переход в редактор (`/widgets/:id`) - -### Widget Editor (`/widgets/:id`) -- **Левая панель**: список доступных блоков для добавления (BlockLibrary) -- **Центр**: canvas виджета с уже добавленными блоками (drag-and-drop sorting) -- **Правая панель**: настройки выбранного блока (конфигурация) - -Компоненты: -- `BlockLibrary` — список типов блоков с иконками -- `BlockRenderer` — рендерит блок на canvas (все типы) -- `BlockConfigPanel` — форма настройки выбранного блока (зависит от type) -- `WidgetPreview` — preview виджета в реальном времени - -### Типы блоков (компоненты) -``` -components/ -└── blocks/ - ├── TextBlock.tsx - ├── GithubStatsBlock.tsx - ├── GithubLangsBlock.tsx - └── LeetcodeStatsBlock.tsx -``` - -В текущей реализации блоки собраны в `entities/widget/ui/WidgetCanvas.tsx`, а редактор использует `WidgetBlockContent` и конфигурационные панели страницы. - -Каждый блок получает: -- `config` — настройки блока -- `onConfigChange` — callback при изменении настроек -- `preview` — boolean (режим предпросмотра) - -### Drag-and-drop -- `editorBlocks` — CSS Grid с квадратными ячейками и gap. -- Pointer захватывается через drag handle. -- Preview drop-position рассчитывается относительно grid bounds. -- Перед сохранением проверяются границы двух колонок и пересечения блоков. -- Layout сохраняется через `PUT /api/widgets/:widgetId/blocks/reorder`. - -### Настройки ресайза -- Width/height виджета (изначально 600x400) -- Размер блока: `1x1`, `1x2`, `2x1`, `2x2`. -- Resize-анимация использует FLIP и корректно деградирует при reduced motion. -- Canvas растёт по содержимому и прокручивается внешним контейнером. - -### Live preview -- Editor debounce-запрашивает реальные данные после ввода username. -- Без username показывается fallback с просьбой его указать. -- `/api/widgets/:widgetId/preview` не сохраняет изменения и требует auth. - -## Проверка -- [x] Создание виджета → появляется в Dashboard -- [x] Добавление блока → отображается в редакторе -- [x] Drag-and-drop по строкам и колонкам → сохраняется layout -- [x] Редактирование config блока → обновляется live preview -- [x] Удаление блока → исчезает -- [x] Удаление виджета → исчезает из Dashboard -- [x] Чужие виджеты не видны (проверка userId) diff --git a/prompts/phase-4-render.md b/prompts/phase-4-render.md deleted file mode 100644 index 103145d..0000000 --- a/prompts/phase-4-render.md +++ /dev/null @@ -1,55 +0,0 @@ -# Phase 4: Render & Export - -## Goals -- Публичный endpoint для данных и рендера виджета -- Возможность встраивания через iframe -- Генерация embed-кода на клиенте - -## Текущее состояние -- Публичная страница доступна по `/w/:slug`. -- Данные и ошибки блоков рендерятся на сервере через `renderWidgetStats`. -- Клиент отображает `WidgetCanvas`, а не серверный HTML/SVG. -- Для GitHub API поддерживается `GITHUB_TOKEN` для authenticated rate limit. - -## Server - -### GET /api/public/widgets/:slug (public) -- Принимает `slug` виджета -- Проверяет, что виджет существует и `public = true` -- Загружает виджет + все блоки (отсортированные по position) -- Загружает данные каждого блока через stats service -- Возвращает `{ widget, rendered: { blocks, cacheTtlSeconds } }` - -### Stats renderer -`server/src/services/statsService.ts` получает данные для `github-stats`, `github-langs` и `leetcode-stats`, кэширует ответы на 15 минут и возвращает данные или block-level error. - -### GitHub API integration -Для `github-stats` и `github-langs` блоков: -REST API: `https://api.github.com/users/{username}` и `/repos`. - -Без токена лимит быстро исчерпывается. Для live preview рекомендуется `GITHUB_TOKEN` в `.env` и Vercel Environment Variables. - -## Client - -### Public widget page (`/w/:slug`) -- Простая страница с рендером виджета -- Загружает public payload через `/api/public/widgets/:slug`. -- Во время загрузки показывает `AuthTransitionLoader`. - -### Widget visibility toggle -- На странице редактора — чекбокс "Публичный" (save → `widget.public`) -- Если непубличный — render endpoint возвращает 404 или заглушку - -### Embed code generator -В редакторе кнопка "Embed" → код с публичным slug: -```html - -``` - -## Проверка -- [x] Публичный виджет рендерится по `/api/public/widgets/:slug` -- [x] Приватный виджет → 404 -- [x] iframe-код корректно генерируется на клиенте -- [x] GitHub stats/langs блоки тянут реальные данные при настроенном `GITHUB_TOKEN` -- [x] LeetCode stats block использует актуальный GraphQL query From 1af51222823fbec8de33421409c0257e336ffaad Mon Sep 17 00:00:00 2001 From: TheGeniusOfEternity Date: Tue, 4 Aug 2026 22:39:38 +0300 Subject: [PATCH 2/4] chore: preserve opencode config --- opencode.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 opencode.json diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..58a010d --- /dev/null +++ b/opencode.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "figma": { + "type": "local", + "command": ["npx", "-y", "figma-developer-mcp", "--stdio"], + "environment": { + "FIGMA_ACCESS_TOKEN": "{env:FIGMA_ACCESS_TOKEN}" + }, + "enabled": true, + "timeout": 15000 + } + } +} From 7600592732b121eaf948ffc786f46a12e9191463 Mon Sep 17 00:00:00 2001 From: TheGeniusOfEternity Date: Tue, 4 Aug 2026 23:07:29 +0300 Subject: [PATCH 3/4] fix: resolve widget loading and LeetCode stats bugs --- client/src/app/App.tsx | 19 ++++- client/src/entities/widget/index.ts | 6 +- .../src/entities/widget/ui/WidgetCanvas.tsx | 9 +- .../entities/widget/ui/WidgetCard.module.css | 21 +++++ client/src/entities/widget/ui/WidgetCard.tsx | 82 +++++++++++++++---- .../ui/PublicWidgetPage.module.css | 8 +- .../public-widget/ui/PublicWidgetPage.tsx | 16 +++- .../widget-editor/ui/WidgetEditorPage.tsx | 2 +- .../widgets-gallery/ui/WidgetsGalleryPage.tsx | 21 ++++- client/src/shared/api/index.ts | 1 + client/src/shared/api/widgets.ts | 2 + client/src/shared/locale/content.ts | 4 + server/src/services/statsService.ts | 6 +- server/src/statsService.test.ts | 54 ++++++++++++ 14 files changed, 223 insertions(+), 28 deletions(-) create mode 100644 server/src/statsService.test.ts diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 6fdac00..9c2e8c7 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -99,6 +99,7 @@ export const App = () => { const [route, setRoute] = useState(getRoute); const [authTab, setAuthTab] = useState(getAuthTab); const [visibleWidgets, setVisibleWidgets] = useState([]); + const [loadedWidgetsKey, setLoadedWidgetsKey] = useState(null); const [themeReveal, setThemeReveal] = useState(null); const [isLocaleTransitioning, setLocaleTransitioning] = useState(false); const [isBootstrapped, setBootstrapped] = useState(false); @@ -107,6 +108,11 @@ export const App = () => { const authUser = useAuthStore((state) => state.user); const authError = useAuthStore((state) => state.error); const prefersReducedMotion = useReducedMotion(); + const widgetLoadKey = + authStatus === 'authenticated' && (route === 'dashboard' || route === 'editor') + ? `${authUser?.id ?? 'current'}:${route}` + : null; + const isWidgetsLoading = widgetLoadKey !== null && loadedWidgetsKey !== widgetLoadKey; const navigate = useCallback((path: string, replace = false) => { if (replace) window.history.replaceState({}, '', path); @@ -160,19 +166,23 @@ export const App = () => { }, [navigate]); useEffect(() => { - if (authStatus !== 'authenticated' || (route !== 'dashboard' && route !== 'editor')) return; + if (!widgetLoadKey) return; let cancelled = false; void listWidgets() .then((nextWidgets) => { - if (!cancelled) setVisibleWidgets(nextWidgets.map(toCardData)); + if (cancelled) return; + setVisibleWidgets(nextWidgets.map(toCardData)); + setLoadedWidgetsKey(widgetLoadKey); }) .catch(() => { - if (!cancelled) setVisibleWidgets([]); + if (cancelled) return; + setVisibleWidgets([]); + setLoadedWidgetsKey(widgetLoadKey); }); return () => { cancelled = true; }; - }, [authStatus, route]); + }, [widgetLoadKey]); useEffect(() => { if ( @@ -340,6 +350,7 @@ export const App = () => { theme={theme} username={username} widgets={visibleWidgets} + isWidgetsLoading={isWidgetsLoading} isLanguageLoading={isLocaleTransitioning} onLocaleToggle={handleLocaleToggle} onThemeToggle={handleThemeToggle} diff --git a/client/src/entities/widget/index.ts b/client/src/entities/widget/index.ts index 0da54ae..372c3db 100644 --- a/client/src/entities/widget/index.ts +++ b/client/src/entities/widget/index.ts @@ -1,4 +1,8 @@ -export { WidgetCard, type WidgetCardLabels } from '@/entities/widget/ui/WidgetCard'; +export { + WidgetCard, + WidgetCardSkeleton, + type WidgetCardLabels, +} from '@/entities/widget/ui/WidgetCard'; export { WidgetBlockContent, WidgetCanvas, diff --git a/client/src/entities/widget/ui/WidgetCanvas.tsx b/client/src/entities/widget/ui/WidgetCanvas.tsx index d624b9f..34764df 100644 --- a/client/src/entities/widget/ui/WidgetCanvas.tsx +++ b/client/src/entities/widget/ui/WidgetCanvas.tsx @@ -9,6 +9,7 @@ import type { WidgetBlock, } from '@/entities/widget/model'; import { paletteTokens } from '@/entities/widget/model'; +import { messages } from '@/shared/locale/content'; import styles from '@/entities/widget/ui/WidgetCanvas.module.css'; type WidgetCanvasProps = { @@ -169,6 +170,7 @@ export const WidgetBlockContent = ({ rendered?: RenderedBlock; locale?: WidgetLocale; }) => { + const t = messages[locale]; if (rendered?.error) return

{rendered.error}

; const source = block.type.startsWith('github') ? 'github' @@ -270,10 +272,13 @@ export const WidgetBlockContent = ({
{block.config.showRanking !== false && ( - + )} {block.config.showContestRating !== false && ( - + )}
diff --git a/client/src/entities/widget/ui/WidgetCard.module.css b/client/src/entities/widget/ui/WidgetCard.module.css index 969ce24..080bcf5 100644 --- a/client/src/entities/widget/ui/WidgetCard.module.css +++ b/client/src/entities/widget/ui/WidgetCard.module.css @@ -285,6 +285,27 @@ padding: 0; } +.skeletonActions { + display: flex; + gap: 8px; + padding-top: 4px; +} + +.skeletonAction { + display: block; + width: 30px; + height: 30px; + border-radius: 10px; + background: linear-gradient( + 100deg, + color-mix(in srgb, var(--muted) 16%, transparent) 20%, + color-mix(in srgb, var(--action) 30%, transparent) 45%, + color-mix(in srgb, var(--muted) 16%, transparent) 70% + ); + background-size: 220% 100%; + animation: card-skeleton-shimmer 1s linear infinite; +} + @media (max-width: 760px) { .configureAction { display: none; diff --git a/client/src/entities/widget/ui/WidgetCard.tsx b/client/src/entities/widget/ui/WidgetCard.tsx index 817cea2..dd86bc6 100644 --- a/client/src/entities/widget/ui/WidgetCard.tsx +++ b/client/src/entities/widget/ui/WidgetCard.tsx @@ -3,7 +3,7 @@ import { Button, Card, Icon, Modal } from '@gravity-ui/uikit'; import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { paletteTokens, type WidgetCardData } from '@/entities/widget/model'; -import { getPublicWidgetUrl } from '@/shared/api'; +import { getPublicWidgetUrl, PUBLIC_WIDGET_MESSAGE_SOURCE } from '@/shared/api'; import styles from '@/entities/widget/ui/WidgetCard.module.css'; export type WidgetCardLabels = { @@ -30,10 +30,55 @@ type WidgetCardProps = { isLanguageLoading: boolean; }; +const PreviewSkeleton = () => ( + +); + const WidgetPreviewFrame = ({ widget }: { widget: WidgetCardData }) => { const viewportRef = useRef(null); - const [isLoaded, setLoaded] = useState(false); + const iframeRef = useRef(null); + const [loadedSlug, setLoadedSlug] = useState(null); const [scale, setScale] = useState(1); + const isLoaded = loadedSlug === widget.slug; + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if ( + event.origin !== window.location.origin || + event.source !== iframeRef.current?.contentWindow || + !event.data || + typeof event.data !== 'object' + ) { + return; + } + + const message = event.data as { + source?: unknown; + type?: unknown; + slug?: unknown; + }; + if ( + message.source !== PUBLIC_WIDGET_MESSAGE_SOURCE || + message.slug !== widget.slug || + (message.type !== 'ready' && message.type !== 'error') + ) { + return; + } + + setLoadedSlug(widget.slug); + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [widget.slug]); useEffect(() => { const viewport = viewportRef.current; @@ -62,18 +107,9 @@ const WidgetPreviewFrame = ({ widget }: { widget: WidgetCardData }) => { className={`${styles.previewFrameViewport} ${isLoaded ? styles.previewFrameViewportLoaded : ''}`} aria-busy={!isLoaded} > - {!isLoaded && ( - - )} + {!isLoaded && }