Connect Meshkee backend, blog and shared account session

This commit is contained in:
Alireza Hassani
2026-09-05 19:41:56 +03:30
parent ee4b384238
commit de46b50e13
33 changed files with 803 additions and 11 deletions
+29
View File
@@ -0,0 +1,29 @@
import { cache } from 'react';
export const DOMAIN = 'sgemed.com';
export const API_BASE = 'https://api.meshkee.com/api/v1';
export const CUSTOMER_URL = 'https://customer.sgemed.com';
export const CUSTOMER_PROFILE_URL = `${CUSTOMER_URL}/profile`;
export const WEBSITE_MANAGEMENT_URL = 'https://business.sgemed.com/website';
export class ApiError extends Error {
constructor(public status: number) { super(`Meshkee request failed (${status})`); }
}
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, { ...init, cache: 'no-store', signal: AbortSignal.timeout(12000) });
if (!response.ok) throw new ApiError(response.status);
return response.json() as Promise<T>;
}
export const getTenant = cache(() => api<{id: number | string; nameFa: string}>(`/tenants/${DOMAIN}`));
export async function tenantApi<T>(path: string, init?: RequestInit): Promise<T> {
await getTenant();
return api<T>(`/tenants/${DOMAIN}${path}`, init);
}
export function safeUrl(value: unknown): string | undefined {
if (typeof value !== 'string' || !value) return;
try { const url = new URL(value); if (url.protocol === 'https:') return url.href; } catch {}
}
export async function getFavicon() {
try {
const data = await tenantApi<{faviconUrl: string | null}>('/website/favicon');
return safeUrl(data.faviconUrl) || '/favicon.svg';
} catch { return '/favicon.svg'; }
}
+28
View File
@@ -0,0 +1,28 @@
import { cache } from 'react';
import sanitizeHtml from 'sanitize-html';
import { tenantApi, safeUrl } from './api';
export type Blog = { id: string | number; title: string; slug?: string; excerpt?: string; summary?: string; content?: string; text?: string; coverUrl?: string; imageUrl?: string; publishedAt?: string };
export type BlogList = { items: Blog[]; total: number; page: number; pageSize: number };
export const getBlogs = cache(async (page: number) => {
const data = await tenantApi<BlogList>(`/blogs?page=${page}&pageSize=12`);
if (!Array.isArray(data.items) || typeof data.total !== 'number') throw new Error('Invalid blog list response');
return data;
});
export const getBlog = cache(async (id: string) => {
const data = await tenantApi<{blog: Blog}>(`/blogs/by-id/${encodeURIComponent(id)}`);
if (!data.blog || !data.blog.id || typeof data.blog.title !== 'string') throw new Error('Invalid blog detail response');
return data.blog;
});
export function titleSlug(title: string) { return title.trim().replace(/[^\p{L}\p{N}\s-]/gu, '').replace(/[\s-]+/g, '-') || 'article'; }
export function blogHref(blog: Blog) { return `/blog/${encodeURIComponent(blog.id)}/${encodeURIComponent(titleSlug(blog.title))}`; }
export function plainText(value: string) { return sanitizeHtml(value, {allowedTags: [], allowedAttributes: {}}); }
export function summary(blog: Blog) { return plainText(blog.excerpt || blog.summary || blog.content || blog.text || '').slice(0,160); }
export function cover(blog: Blog) { return safeUrl(blog.coverUrl || blog.imageUrl); }
export function articleHtml(blog: Blog) {
return sanitizeHtml(blog.content || blog.text || '', {
allowedTags: ['p','br','h2','h3','h4','strong','b','em','i','ul','ol','li','blockquote','a','img','figure','figcaption','table','thead','tbody','tr','th','td','hr','pre','code'],
allowedAttributes: {a:['href','title'],img:['src','alt','width','height']},
allowedSchemes: ['https','http','mailto'], allowedSchemesByTag: {img:['https']},
allowProtocolRelative: false, transformTags: {h1:'h2'},
});
}
+17
View File
@@ -0,0 +1,17 @@
export function normalizeCell(value: string) {
const latin=value.replace(/[۰-۹]/g,c=>String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c))).replace(/[٠-٩]/g,c=>String('٠١٢٣٤٥٦٧٨٩'.indexOf(c))).replace(/[\s()-]/g,'');
if(latin.startsWith('09'))return '+98'+latin.slice(1);
if(latin.startsWith('0098'))return '+'+latin.slice(2);
return latin;
}
export function validateContact(input: Record<string,unknown>) {
const fields=['title','name','email','cellNumber','text'] as const;
const data=Object.fromEntries(fields.map(key=>[key,typeof input[key]==='string'?input[key].trim():''])) as Record<typeof fields[number],string>;
const errors:Record<string,string>={};
for(const key of ['title','name','text'] as const)if(!data[key])errors[key]='تکمیل این بخش ضروری است.';
for(const [key,max] of Object.entries({title:200,name:120,email:254,cellNumber:25,text:5000}))if(data[key as keyof typeof data].length>max)errors[key]='متن واردشده بیش از حد طولانی است.';
if(data.email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email))errors.email='ایمیل معتبر وارد کنید.';
data.cellNumber=normalizeCell(data.cellNumber);
if(data.cellNumber&&!/^\+[1-9]\d{7,14}$/.test(data.cellNumber))errors.cellNumber='شماره را با ۰۹ یا کد کشور وارد کنید.';
return {data,errors};
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
export const ACCESS_COOKIE='meshkee_customer_access_token';
export const REFRESH_COOKIE='meshkee_customer_refresh_token';
export function cookieOptions(request:Request){
const host=(request.headers.get('host')||'').split(':')[0];
const production=host==='sgemed.com'||host.endsWith('.sgemed.com');
return {path:'/',httpOnly:true,sameSite:'lax' as const,secure:production||new URL(request.url).protocol==='https:',...(production?{domain:'.sgemed.com'}:{})};
}
export function setSessionCookies(response:NextResponse,request:Request,tokens:{accessToken:string;refreshToken:string}){
// Session lifetime remains controlled by the backend; never infer claims from an unverified JWT.
const options=cookieOptions(request);
response.cookies.set(ACCESS_COOKIE,tokens.accessToken,options);
response.cookies.set(REFRESH_COOKIE,tokens.refreshToken,options);
}
export function sameOrigin(request:Request){
const origin=request.headers.get('origin');
if(!origin)return false;
try{return new URL(origin).host===request.headers.get('host')}catch{return false}
}