29d1386dd9
Две линии разработки разъехались и месяц жили порознь: в Gitea уехали электронные визитки с QR-кодом, а в рабочей копии на fortress остались шесть коммитов, которые до репозитория не дошли и, значит, не попали на сайт — английская версия с переключателем языков, карточка Бонитировки, логотипы продуктов, реквизиты компании в подвале, убранная кнопка Telegram, имя директора латиницей. Плюс незакоммиченное бургер-меню. Что разрешено вручную: 1. Landing.tsx, блок контактов. Столкнулись наша расстановка (почта главной кнопкой с подписью из cta_primary, телефон второй) и правка из Gitea, переводящая оба контакта с href на onClick, чтобы адрес и номер не лежали в разметке и их не собирали боты. Взято и то, и другое: защита от ботов сохранена, порядок и оформление наши, адрес почты в подписи не светится. 2. Коллизия номеров миграций: обе стороны завели номер 003. На живой базе применены 001, 002 и 003_business_cards, а 003_product_url_and_requisites не выполнялась никогда — она и переименована в 004, чтобы раннер (он отслеживает миграции по имени файла) отработал её штатно. 3. package-lock пересобран под слитый манифест, а не выбран со стороны. Проверено: tsc чист, vite build проходит, 118 модулей, 241 КБ.
144 lines
4.5 KiB
TypeScript
144 lines
4.5 KiB
TypeScript
const API_BASE = import.meta.env.VITE_API_URL || '';
|
|
|
|
export interface Product {
|
|
id: number;
|
|
slug: string;
|
|
site_url?: string | null;
|
|
title: string;
|
|
subtitle: string | null;
|
|
description: string;
|
|
status: 'production' | 'development' | 'planned';
|
|
audience: 'B2B' | 'B2C' | 'global' | null;
|
|
icon_key: string | null;
|
|
tags: string[];
|
|
sort_order: number;
|
|
title_en?: string | null;
|
|
subtitle_en?: string | null;
|
|
description_en?: string | null;
|
|
logo_url?: string | null;
|
|
brand_color?: string | null;
|
|
}
|
|
|
|
export interface ApproachItem {
|
|
id: number;
|
|
title: string;
|
|
description: string;
|
|
icon_key: string | null;
|
|
sort_order: number;
|
|
title_en?: string | null;
|
|
description_en?: string | null;
|
|
}
|
|
|
|
export interface BusinessCard {
|
|
id: number;
|
|
slug: string;
|
|
name: string;
|
|
role: string | null;
|
|
company: string | null;
|
|
phone: string | null;
|
|
email: string | null;
|
|
telegram: string | null;
|
|
photo: string | null;
|
|
product_title: string | null;
|
|
product_subtitle: string | null;
|
|
product_url: string | null;
|
|
site_url: string | null;
|
|
is_published?: boolean;
|
|
}
|
|
|
|
export interface LandingContent {
|
|
sections: Record<string, Record<string, unknown>>;
|
|
sections_en?: Record<string, Record<string, unknown>>;
|
|
products: Product[];
|
|
approach: ApproachItem[];
|
|
settings: Record<string, string>;
|
|
}
|
|
|
|
async function request<T>(
|
|
path: string,
|
|
options: RequestInit = {}
|
|
): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(options.headers || {}),
|
|
},
|
|
...options,
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.error || `request_failed_${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export const api = {
|
|
getContent: () => request<LandingContent>('/api/content'),
|
|
|
|
login: (login: string, password: string) =>
|
|
request<{ ok: true; login: string }>('/api/admin/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ login, password }),
|
|
}),
|
|
logout: () =>
|
|
request<{ ok: true }>('/api/admin/logout', { method: 'POST' }),
|
|
me: () => request<{ admin: { id: number; login: string } }>('/api/admin/me'),
|
|
|
|
listProducts: () =>
|
|
request<{ products: Product[] }>('/api/admin/products'),
|
|
updateProduct: (id: number, data: Partial<Product>) =>
|
|
request<{ product: Product }>(`/api/admin/products/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
createProduct: (data: Omit<Product, 'id'>) =>
|
|
request<{ product: Product }>('/api/admin/products', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteProduct: (id: number) =>
|
|
request<{ ok: true }>(`/api/admin/products/${id}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
listSections: () => request<{ sections: unknown[] }>('/api/admin/sections'),
|
|
updateSection: (key: string, data: { content_json?: unknown; title?: string }) =>
|
|
request<{ section: unknown }>(`/api/admin/sections/${key}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
|
|
listApproach: () => request<{ items: ApproachItem[] }>('/api/admin/approach'),
|
|
updateApproach: (id: number, data: Partial<ApproachItem>) =>
|
|
request<{ item: ApproachItem }>(`/api/admin/approach/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
|
|
listSettings: () =>
|
|
request<{ settings: Record<string, string> }>('/api/admin/settings'),
|
|
updateSetting: (key: string, value: string) =>
|
|
request<{ ok: true }>(`/api/admin/settings/${key}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ value }),
|
|
}),
|
|
|
|
getCard: (slug: string) =>
|
|
request<{ card: BusinessCard }>(`/api/cards/${slug}`),
|
|
listCards: () =>
|
|
request<{ cards: BusinessCard[] }>('/api/admin/cards'),
|
|
createCard: (data: Partial<BusinessCard>) =>
|
|
request<{ card: BusinessCard }>('/api/admin/cards', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateCard: (id: number, data: Partial<BusinessCard>) =>
|
|
request<{ card: BusinessCard }>(`/api/admin/cards/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteCard: (id: number) =>
|
|
request<{ ok: true }>(`/api/admin/cards/${id}`, { method: 'DELETE' }),
|
|
};
|