Merge pull request #2 from SamandarTurgunboyev/samandar

Samandar
This commit is contained in:
Samandar Turg'unboev
2025-11-04 19:24:12 +05:00
committed by GitHub
18 changed files with 429 additions and 193 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
.env.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
*.md
.vscode
.idea

1
.env
View File

@@ -1 +0,0 @@
VITE_API_URL=https://simple-travel.felixits.uz/api/v1/

View File

@@ -1 +0,0 @@
VITE_API_URL=string

4
.gitignore vendored
View File

@@ -7,7 +7,11 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
#env add gitignore
.env .env
.env.production
.example.env
node_modules node_modules
dist dist

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Build stage
FROM node:20-alpine
WORKDIR /app
# Copy dependencies
COPY package.json package-lock.json* ./
RUN npm ci --legacy-peer-deps
# Copy all source
COPY . .
# Set production env (agar .env.production bolsa ishlaydi)
ENV NODE_ENV=production
# Build for production
RUN npm run build
ENTRYPOINT npm run preview

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
simple-travel-admin:
build:
context: .
dockerfile: Dockerfile
args:
- VITE_API_URL=${VITE_API_URL}
- VITE_APP_NAME=${VITE_APP_NAME}
container_name: simple-travel-admin
ports:
- "5263:5263"
env_file:
- .env.production
restart: unless-stopped
networks:
- network
networks:
network:
driver: bridge

33
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,33 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# React Router support
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Don't cache index.html
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
}

View File

@@ -6,7 +6,7 @@ interface NewsData {
desc: string; desc: string;
title_ru: string; title_ru: string;
desc_ru: string; desc_ru: string;
category: string; category: number | null;
banner: File | undefined | string; banner: File | undefined | string;
} }
@@ -20,7 +20,7 @@ export const useNewsStore = create<NewsStore>((set) => ({
stepOneData: { stepOneData: {
title: "", title: "",
desc: "", desc: "",
category: "", category: null,
banner: undefined, banner: undefined,
desc_ru: "", desc_ru: "",
title_ru: "", title_ru: "",
@@ -31,7 +31,7 @@ export const useNewsStore = create<NewsStore>((set) => ({
stepOneData: { stepOneData: {
title: "", title: "",
desc: "", desc: "",
category: "", category: null,
banner: undefined, banner: undefined,
desc_ru: "", desc_ru: "",
title_ru: "", title_ru: "",

View File

@@ -18,12 +18,13 @@ export const newsForm = z.object({
desc_ru: z.string().min(2, { desc_ru: z.string().min(2, {
message: "Kamida 2 ta belgidan iborat bolishi kerak.", message: "Kamida 2 ta belgidan iborat bolishi kerak.",
}), }),
category: z.string().min(1, { category: z.number().min(1, {
message: "Majburiy maydon", message: "Majburiy maydon",
}), }),
banner: fileSchema, banner: fileSchema,
}); });
// zod schema ni yangilaymiz
export const newsPostForm = z.object({ export const newsPostForm = z.object({
desc: z desc: z
.string() .string()
@@ -36,17 +37,22 @@ export const newsPostForm = z.object({
sections: z sections: z
.array( .array(
z.object({ z.object({
image: fileSchema, image: z.union([z.instanceof(File), z.string()]).optional(),
text: z.string().min(1, { message: "Matn bo'sh bo'lmasligi kerak." }), text: z.string().optional(),
text_ru: z text_ru: z.string().optional(),
.string()
.min(1, { message: "Ruscha matn bo'sh bo'lmasligi kerak." }),
}), }),
) )
.min(1, { message: "Kamida bitta bolim qoshing." }), .optional(),
post_tags: z post_tags: z
.array(z.string().min(1, { message: "Teg bo'sh bo'lmasligi kerak." })) .array(
z.object({
name: z.string().min(1, { message: "Teg bo'sh bo'lmasligi kerak." }),
name_ru: z
.string()
.min(1, { message: "Teg (RU) bo'sh bo'lmasligi kerak." }),
}),
)
.min(1, { message: "Kamida bitta teg kiriting." }), .min(1, { message: "Kamida bitta teg kiriting." }),
}); });

View File

@@ -98,12 +98,8 @@ export interface NewsDetail {
text_ru: string; text_ru: string;
text_uz: string; text_uz: string;
is_public: boolean; is_public: boolean;
category: { category: { id: number; name: string; name_ru: string; name_uz: string };
name: string; post_tags: [
name_ru: string;
name_uz: string;
};
tag: [
{ {
id: number; id: number;
name: string; name: string;

View File

@@ -4,7 +4,7 @@ import StepOne from "@/pages/news/ui/StepOne";
import StepTwo from "@/pages/news/ui/StepTwo"; import StepTwo from "@/pages/news/ui/StepTwo";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
@@ -13,7 +13,7 @@ const AddNews = () => {
const isEditMode = useMemo(() => !!id, [id]); const isEditMode = useMemo(() => !!id, [id]);
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useQuery({ const { data, refetch } = useQuery({
queryKey: ["news_detail", id], queryKey: ["news_detail", id],
queryFn: () => getDetailNews(Number(id)), queryFn: () => getDetailNews(Number(id)),
select(data) { select(data) {
@@ -22,6 +22,12 @@ const AddNews = () => {
enabled: !!id, enabled: !!id,
}); });
useEffect(() => {
if (id) {
refetch();
}
}, [id]);
return ( return (
<div className="p-8 w-full mx-auto bg-gray-900 text-white rounded-2xl shadow-lg"> <div className="p-8 w-full mx-auto bg-gray-900 text-white rounded-2xl shadow-lg">
<h1 className="text-3xl font-bold mb-6"> <h1 className="text-3xl font-bold mb-6">
@@ -41,9 +47,16 @@ const AddNews = () => {
</div> </div>
</div> </div>
{step === 1 && ( {step === 1 && (
<StepOne isEditMode={isEditMode} setStep={setStep} data={data!} /> <StepOne
isEditMode={isEditMode}
setStep={setStep}
data={data!}
id={Number(id)}
/>
)}
{step === 2 && (
<StepTwo key={id} data={data!} isEditMode={isEditMode} id={id!} />
)} )}
{step === 2 && <StepTwo data={data!} isEditMode={isEditMode} id={id!} />}
</div> </div>
); );
}; };

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { deleteNews, getAllNews } from "@/pages/news/lib/api"; import { deleteNews, getAllNews, updateNews } from "@/pages/news/lib/api";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card"; import { Card } from "@/shared/ui/card";
@@ -10,12 +10,15 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { Switch } from "@/shared/ui/switch";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import clsx from "clsx"; import clsx from "clsx";
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Edit, Edit,
Eye,
EyeOff,
FolderOpen, FolderOpen,
Loader2, Loader2,
PlusCircle, PlusCircle,
@@ -41,11 +44,33 @@ const News = () => {
queryFn: () => getAllNews({ page: currentPage, page_size: 10 }), queryFn: () => getAllNews({ page: currentPage, page_size: 10 }),
}); });
const { mutate, isPending } = useMutation({ const { mutate: deleteMutate, isPending } = useMutation({
mutationFn: (id: number) => deleteNews(id), mutationFn: (id: number) => deleteNews(id),
onSuccess: () => { onSuccess: () => {
setDeleteId(null); setDeleteId(null);
queryClient.refetchQueries({ queryKey: ["all_news"] }); queryClient.refetchQueries({ queryKey: ["all_news"] });
toast.success(t("Yangilik o'chirildi"), {
richColors: true,
position: "top-center",
});
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
const { mutate: togglePublicMutate } = useMutation({
mutationFn: ({ id, body }: { id: number; body: FormData }) =>
updateNews({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["all_news"] });
toast.success(t("Status o'zgartirildi"), {
richColors: true,
position: "top-center",
});
}, },
onError: () => { onError: () => {
toast.error(t("Xatolik yuz berdi"), { toast.error(t("Xatolik yuz berdi"), {
@@ -57,10 +82,21 @@ const News = () => {
const confirmDelete = () => { const confirmDelete = () => {
if (deleteId) { if (deleteId) {
mutate(deleteId); deleteMutate(deleteId);
} }
}; };
const handleTogglePublic = (id: number, currentStatus: boolean) => {
const formData = new FormData();
console.log(currentStatus);
formData.append("is_public", String(currentStatus));
togglePublicMutate({
id,
body: formData,
});
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-gray-900 w-full text-white flex justify-center items-center"> <div className="min-h-screen bg-gray-900 w-full text-white flex justify-center items-center">
@@ -135,10 +171,10 @@ const News = () => {
allNews?.data.data.results.map((item) => ( allNews?.data.data.results.map((item) => (
<Card <Card
key={item.id} key={item.id}
className="overflow-hidden bg-neutral-900 hover:bg-neutral-800 transition-all duration-300 border border-neutral-800 hover:border-neutral-700 group" className="overflow-hidden p-0 bg-neutral-900 hover:bg-neutral-800 transition-all duration-300 border border-neutral-800 hover:border-neutral-700 group flex flex-col"
> >
{/* Image */} {/* Image */}
<div className="relative h-48 overflow-hidden"> <div className="relative h-64 overflow-hidden">
<img <img
src={item.image} src={item.image}
alt={item.title} alt={item.title}
@@ -158,7 +194,7 @@ const News = () => {
</div> </div>
{/* Content */} {/* Content */}
<div className="p-4 space-y-3"> <div className="p-4 space-y-3 flex-1 flex flex-col">
{/* Title */} {/* Title */}
<h2 className="text-xl font-bold line-clamp-2 group-hover:text-blue-400 transition-colors"> <h2 className="text-xl font-bold line-clamp-2 group-hover:text-blue-400 transition-colors">
{item.title} {item.title}
@@ -169,21 +205,47 @@ const News = () => {
{item.text} {item.text}
</p> </p>
{/* Date */}
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>{item.is_public}</span>
</div>
{/* Slug */} {/* Slug */}
<div className="pt-2 border-t border-neutral-800"> {item.tag && item.tag.length > 0 && (
{item.tag?.map((e) => ( <div className="pt-2 border-t border-neutral-800 flex flex-wrap gap-2">
<code className="text-xs text-gray-500 bg-neutral-800 px-2 py-1 rounded"> {item.tag.map((e, idx) => (
/{e.name} <code
</code> key={idx}
))} className="text-xs text-gray-500 bg-neutral-800 px-2 py-1 rounded"
>
/{e.name}
</code>
))}
</div>
)}
{/* Spacer to push content to bottom */}
<div className="flex-1"></div>
{/* Public/Private Toggle */}
<div className="pt-3 border-t border-neutral-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{item.is_public ? (
<Eye size={16} className="text-green-500" />
) : (
<EyeOff size={16} className="text-gray-500" />
)}
<span className="text-sm text-gray-400">
{t("Оmmaviy")}
</span>
</div>
<Switch
checked={item.is_public}
onCheckedChange={() =>
handleTogglePublic(item.id, !item.is_public)
}
className="data-[state=checked]:bg-green-600"
/>
</div>
</div> </div>
{/* Actions */} {/* Actions - at the very bottom */}
<div className="flex justify-end gap-2 pt-3"> <div className="flex justify-end gap-2 pt-3">
<Button <Button
onClick={() => navigate(`/news/edit/${item.id}`)} onClick={() => navigate(`/news/edit/${item.id}`)}
@@ -240,7 +302,8 @@ const News = () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<div className="flex justify-end gap-2"> {/* Pagination */}
<div className="flex justify-end gap-2 w-[90%] mx-auto mt-8">
<button <button
disabled={currentPage === 1} disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))} onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}

View File

@@ -34,11 +34,12 @@ interface Data {
text_uz: string; text_uz: string;
is_public: boolean; is_public: boolean;
category: { category: {
id: number;
name: string; name: string;
name_ru: string; name_ru: string;
name_uz: string; name_uz: string;
}; };
tag: [ post_tags: [
{ {
id: number; id: number;
name: string; name: string;
@@ -63,62 +64,83 @@ const StepOne = ({
setStep: Dispatch<SetStateAction<number>>; setStep: Dispatch<SetStateAction<number>>;
isEditMode: boolean; isEditMode: boolean;
data: Data; data: Data;
id: number;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setStepOneData, stepOneData } = useNewsStore(); const { setStepOneData, stepOneData } = useNewsStore();
const hasReset = useRef(false); // 👈 infinite loopni oldini olish uchun const hasReset = useRef(false);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = // News Category fetch - barcha kategoriyalarni bir martada
useInfiniteQuery({ const {
queryKey: ["news_category", detail], data: categoryData,
queryFn: ({ pageParam = 1 }) => fetchNextPage,
getAllNewsCategory({ page: pageParam, page_size: 10 }), hasNextPage,
getNextPageParam: (lastPage) => { isFetchingNextPage,
const currentPage = lastPage.data.data.current_page; isLoading: isCategoriesLoading,
const totalPages = lastPage.data.data.total_pages; } = useInfiniteQuery({
return currentPage < totalPages ? currentPage + 1 : undefined; queryKey: ["news_category"],
}, queryFn: ({ pageParam = 1 }) =>
initialPageParam: 1, getAllNewsCategory({ page: pageParam, page_size: 100 }),
}); getNextPageParam: (lastPage) => {
const currentPage = lastPage.data.data.current_page;
const totalPages = lastPage.data.data.total_pages;
return currentPage < totalPages ? currentPage + 1 : undefined;
},
initialPageParam: 1,
});
// Avtomatik barcha sahifalarni yuklash
useEffect(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const allCategories = const allCategories =
data?.pages.flatMap((page) => page.data.data.results) ?? []; categoryData?.pages.flatMap((page) => page.data.data.results) ?? [];
// Form setup
const form = useForm<z.infer<typeof newsForm>>({ const form = useForm<z.infer<typeof newsForm>>({
resolver: zodResolver(newsForm), resolver: zodResolver(newsForm),
defaultValues: { defaultValues: {
title: stepOneData.title, title: stepOneData.title || "",
category: stepOneData.category, category: stepOneData.category || undefined,
banner: stepOneData.banner, banner: stepOneData.banner,
desc: stepOneData.desc, desc: stepOneData.desc || "",
desc_ru: stepOneData.desc_ru, desc_ru: stepOneData.desc_ru || "",
title_ru: stepOneData.title_ru, title_ru: stepOneData.title_ru || "",
}, },
}); });
// ✅ reset faqat bir marta, ma'lumot tayyor bo'lganda ishlaydi // Reset form when detail & categories are ready (EDIT MODE)
useEffect(() => { useEffect(() => {
if ( if (
detail && !detail ||
allCategories.length > 0 && allCategories.length === 0 ||
!hasReset.current // faqat bir marta hasReset.current ||
isCategoriesLoading
) { ) {
const foundCategory = allCategories.find( return;
(cat) => cat.name === detail.category.name,
);
form.reset({
banner: detail.image as any,
category: foundCategory ? String(foundCategory.id) : "",
title: detail.title_uz,
title_ru: detail.title_ru,
desc: detail.text_uz,
desc_ru: detail.text_ru,
});
hasReset.current = true; // ✅ qayta reset bolmasin
} }
}, [detail, allCategories, form]);
const categoryId = detail.category.id;
form.reset({
banner: detail.image as any,
category: categoryId,
title: detail.title_uz,
title_ru: detail.title_ru,
desc: detail.text_uz,
desc_ru: detail.text_ru,
});
// Kategoriyani alohida set qilish
setTimeout(() => {
form.setValue("category", categoryId, { shouldValidate: false });
}, 0);
hasReset.current = true;
}, [detail, allCategories, form, isCategoriesLoading]);
function onSubmit(values: z.infer<typeof newsForm>) { function onSubmit(values: z.infer<typeof newsForm>) {
setStepOneData(values); setStepOneData(values);
@@ -135,7 +157,7 @@ const StepOne = ({
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 bg-gray-900" className="space-y-6 bg-gray-900"
> >
{/* title */} {/* Title */}
<FormField <FormField
control={form.control} control={form.control}
name="title" name="title"
@@ -154,7 +176,7 @@ const StepOne = ({
)} )}
/> />
{/* title_ru */} {/* Title RU */}
<FormField <FormField
control={form.control} control={form.control}
name="title_ru" name="title_ru"
@@ -173,7 +195,7 @@ const StepOne = ({
)} )}
/> />
{/* desc */} {/* Description */}
<FormField <FormField
control={form.control} control={form.control}
name="desc" name="desc"
@@ -192,7 +214,7 @@ const StepOne = ({
)} )}
/> />
{/* desc_ru */} {/* Description RU */}
<FormField <FormField
control={form.control} control={form.control}
name="desc_ru" name="desc_ru"
@@ -211,36 +233,44 @@ const StepOne = ({
)} )}
/> />
{/* category */} {/* Category */}
<FormField <FormField
control={form.control} control={form.control}
name="category" name="category"
render={({ field }) => ( render={({ field }) => {
<FormItem> console.log("Category field value:", field.value);
<Label className="text-md">{t("Kategoriya")}</Label> console.log("All categories:", allCategories);
<FormControl>
<InfiniteScrollSelect return (
value={field.value} <FormItem>
onValueChange={field.onChange} <Label className="text-md">{t("Kategoriya")}</Label>
placeholder={t("Kategoriya tanlang")} <FormControl>
label={t("Kategoriyalar")} <InfiniteScrollSelect
data={allCategories} value={field.value ? String(field.value) : ""}
hasNextPage={hasNextPage} onValueChange={(value) => {
isFetchingNextPage={isFetchingNextPage} const numValue = Number(value);
fetchNextPage={fetchNextPage} field.onChange(numValue);
renderOption={(cat) => ({ }}
key: cat.id, placeholder={t("Kategoriya tanlang")}
value: String(cat.id), label={t("Kategoriyalar")}
label: cat.name, data={allCategories}
})} hasNextPage={hasNextPage}
/> isFetchingNextPage={isFetchingNextPage}
</FormControl> fetchNextPage={fetchNextPage}
<FormMessage /> renderOption={(cat) => ({
</FormItem> key: cat.id,
)} value: String(cat.id),
label: cat.name,
})}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/> />
{/* banner */} {/* Banner */}
<FormField <FormField
control={form.control} control={form.control}
name="banner" name="banner"
@@ -301,6 +331,7 @@ const StepOne = ({
)} )}
/> />
{/* Submit */}
<div className="w-full flex justify-end"> <div className="w-full flex justify-end">
<Button <Button
type="submit" type="submit"

View File

@@ -38,22 +38,18 @@ interface Data {
name_ru: string; name_ru: string;
name_uz: string; name_uz: string;
}; };
tag: [ post_tags: Array<{
{ id: number;
id: number; name: string;
name: string; name_ru: string;
name_ru: string; name_uz: string;
name_uz: string; }>;
}, post_images: Array<{
]; image: string;
post_images: [ text: string;
{ text_ru: string;
image: string; text_uz: string;
text: string; }>;
text_ru: string;
text_uz: string;
},
];
} }
const StepTwo = ({ const StepTwo = ({
@@ -77,13 +73,12 @@ const StepTwo = ({
desc_ru: "", desc_ru: "",
is_public: "yes", is_public: "yes",
sections: [{ image: undefined as any, text: "", text_ru: "" }], sections: [{ image: undefined as any, text: "", text_ru: "" }],
post_tags: [""], post_tags: [{ name: "", name_ru: "" }],
}, },
}); });
useEffect(() => { useEffect(() => {
if (detail && !hasReset.current) { if (detail && !hasReset.current) {
// 🧠 xavfsiz map qilish
const mappedSections = const mappedSections =
detail.post_images?.map((img) => ({ detail.post_images?.map((img) => ({
image: img.image, image: img.image,
@@ -91,13 +86,18 @@ const StepTwo = ({
text_ru: img.text_ru, text_ru: img.text_ru,
})) ?? []; })) ?? [];
const mappedTags = detail.tag?.map((t) => t.name_uz) ?? []; const mappedTags =
detail.post_tags?.map((t) => ({
name: t.name_uz,
name_ru: t.name_ru,
})) ?? [];
form.reset({ form.reset({
desc: detail.text_uz || "", desc: detail.text_uz || "",
desc_ru: detail.text_ru || "", desc_ru: detail.text_ru || "",
is_public: detail.is_public ? "yes" : "no", is_public: detail.is_public ? "yes" : "no",
post_tags: mappedTags.length > 0 ? mappedTags : [""], post_tags:
mappedTags.length > 0 ? mappedTags : [{ name: "", name_ru: "" }],
sections: sections:
mappedSections.length > 0 mappedSections.length > 0
? mappedSections ? mappedSections
@@ -108,18 +108,31 @@ const StepTwo = ({
} }
}, [detail, form]); }, [detail, form]);
const { fields, append, remove } = useFieldArray({ const {
fields: sectionFields,
append: appendSection,
remove: removeSection,
} = useFieldArray({
control: form.control, control: form.control,
name: "sections", name: "sections",
}); });
const { watch, setValue } = form;
const postTags = watch("post_tags"); const {
const addTag = () => setValue("post_tags", [...postTags, ""]); fields: tagFields,
const removeTag = (i: number) => append: appendTag,
setValue( remove: removeTag,
"post_tags", } = useFieldArray({
postTags.filter((_, idx) => idx !== i), control: form.control,
); name: "post_tags",
});
const handleImageChange = (
e: React.ChangeEvent<HTMLInputElement>,
index: number,
) => {
const file = e.target.files?.[0];
if (file) form.setValue(`sections.${index}.image`, file);
};
const { mutate: added } = useMutation({ const { mutate: added } = useMutation({
mutationFn: (body: FormData) => addNews(body), mutationFn: (body: FormData) => addNews(body),
@@ -139,7 +152,7 @@ const StepTwo = ({
const { mutate: update } = useMutation({ const { mutate: update } = useMutation({
mutationFn: ({ body, id }: { id: number; body: FormData }) => mutationFn: ({ body, id }: { id: number; body: FormData }) =>
updateNews({ id: id, body }), updateNews({ id, body }),
onSuccess: () => { onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["news_detail"] }); queryClient.refetchQueries({ queryKey: ["news_detail"] });
queryClient.refetchQueries({ queryKey: ["all_news"] }); queryClient.refetchQueries({ queryKey: ["all_news"] });
@@ -154,14 +167,6 @@ const StepTwo = ({
}, },
}); });
const handleImageChange = (
e: React.ChangeEvent<HTMLInputElement>,
index: number,
) => {
const file = e.target.files?.[0];
if (file) form.setValue(`sections.${index}.image`, file);
};
const onSubmit = (values: NewsPostFormType) => { const onSubmit = (values: NewsPostFormType) => {
const formData = new FormData(); const formData = new FormData();
@@ -176,28 +181,27 @@ const StepTwo = ({
formData.append("image", stepOneData.banner); formData.append("image", stepOneData.banner);
} }
// 🔥 sections // Sections
values.sections.forEach((section, i) => { values.sections?.forEach((section, i) => {
if (section.image instanceof File) { if (section.image instanceof File)
formData.append(`post_images[${i}]`, section.image); formData.append(`post_images[${i}]`, section.image);
} if (section.text) formData.append(`post_text[${i}]`, section.text);
formData.append(`post_text[${i}]`, section.text); if (section.text_ru)
formData.append(`post_text_ru[${i}]`, section.text_ru); formData.append(`post_text_ru[${i}]`, section.text_ru);
}); });
// Post Tags
values.post_tags.forEach((tag, i) => { values.post_tags.forEach((tag, i) => {
formData.append(`post_tags[${i}]`, tag); formData.append(`post_tags[${i}]name`, tag.name);
formData.append(`post_tags[${i}]name_ru`, tag.name_ru);
}); });
if (id) {
update({ if (id) update({ body: formData, id: Number(id) });
body: formData, else added(formData);
id: Number(id),
});
} else {
added(formData);
}
}; };
console.log(form.formState.errors);
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -239,45 +243,58 @@ const StepTwo = ({
)} )}
/> />
{/* Post Tags */}
<div className="space-y-4"> <div className="space-y-4">
<Label>{t("Teglar")}</Label> <Label>{t("Teglar")}</Label>
{postTags.map((__, i) => ( {tagFields.map((field, i) => (
<FormField <div key={field.id} className="flex gap-2 items-start">
key={i} <FormField
control={form.control} control={form.control}
name={`post_tags.${i}`} name={`post_tags.${i}.name`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="relative">
{postTags.length > 1 && (
<button
type="button"
onClick={() => removeTag(i)}
className="absolute top-1 right-1 text-red-400 hover:text-red-500"
>
<Trash2 className="size-4" />
</button>
)}
<FormControl> <FormControl>
<Input {...field} placeholder={t("Masalan: sport")} /> <Input {...field} placeholder={t("Teg (UZ)")} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</div> </FormItem>
</FormItem> )}
/>
<FormField
control={form.control}
name={`post_tags.${i}.name_ru`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder={t("Teg (RU)")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{tagFields.length > 1 && (
<button
type="button"
onClick={() => removeTag(i)}
className="text-red-400 hover:text-red-500 mt-1"
>
<Trash2 className="size-4" />
</button>
)} )}
/> </div>
))} ))}
<Button <Button
type="button" type="button"
onClick={addTag} onClick={() => appendTag({ name: "", name_ru: "" })}
className="bg-gray-600 hover:bg-gray-700" className="bg-gray-600 hover:bg-gray-700 mt-2"
> >
<PlusCircle className="size-5 mr-2" /> <PlusCircle className="size-5 mr-2" />
{t("Teg qo'shish")} {t("Teg qo'shish")}
</Button> </Button>
</div> </div>
{fields.map((field, index) => ( {/* Sections */}
{sectionFields.map((field, index) => (
<div <div
key={field.id} key={field.id}
className="border border-gray-700 rounded-lg p-4 space-y-4" className="border border-gray-700 rounded-lg p-4 space-y-4"
@@ -288,7 +305,7 @@ const StepTwo = ({
</p> </p>
<button <button
type="button" type="button"
onClick={() => remove(index)} onClick={() => removeSection(index)}
className="text-red-400 hover:text-red-500" className="text-red-400 hover:text-red-500"
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
@@ -341,7 +358,7 @@ const StepTwo = ({
)} )}
</div> </div>
{/* Text (UZ) */} {/* Text UZ */}
<FormField <FormField
control={form.control} control={form.control}
name={`sections.${index}.text`} name={`sections.${index}.text`}
@@ -355,7 +372,7 @@ const StepTwo = ({
)} )}
/> />
{/* Text (RU) */} {/* Text RU */}
<FormField <FormField
control={form.control} control={form.control}
name={`sections.${index}.text_ru`} name={`sections.${index}.text_ru`}
@@ -374,7 +391,7 @@ const StepTwo = ({
<Button <Button
type="button" type="button"
onClick={() => onClick={() =>
append({ image: undefined as any, text: "", text_ru: "" }) appendSection({ image: undefined as any, text: "", text_ru: "" })
} }
className="bg-gray-700 hover:bg-gray-600" className="bg-gray-700 hover:bg-gray-600"
> >

View File

@@ -530,5 +530,8 @@
"Yuborish": "Отправить", "Yuborish": "Отправить",
"Pulni o'tkazish": "Перевод средств", "Pulni o'tkazish": "Перевод средств",
"Tolov muvaffaqiyatli amalga oshirildi!": "Оплата успешно выполнена!", "Tolov muvaffaqiyatli amalga oshirildi!": "Оплата успешно выполнена!",
"Sizga tizimga kirishga ruxsat berilmagan": "Вам не разрешен доступ к системе." "Sizga tizimga kirishga ruxsat berilmagan": "Вам не разрешен доступ к системе.",
"Оmmaviy": "Публичный",
"Shaxsiy": "Личный",
"Status o'zgartirildi": "Статус изменён"
} }

View File

@@ -531,5 +531,8 @@
"Yuborish": "Yuborish", "Yuborish": "Yuborish",
"Pulni o'tkazish": "Pulni o'tkazish", "Pulni o'tkazish": "Pulni o'tkazish",
"Tolov muvaffaqiyatli amalga oshirildi!": "Tolov muvaffaqiyatli amalga oshirildi!", "Tolov muvaffaqiyatli amalga oshirildi!": "Tolov muvaffaqiyatli amalga oshirildi!",
"Sizga tizimga kirishga ruxsat berilmagan": "Sizga tizimga kirishga ruxsat berilmagan" "Sizga tizimga kirishga ruxsat berilmagan": "Sizga tizimga kirishga ruxsat berilmagan",
"Оmmaviy": "Ommaviy",
"Shaxsiy": "Shaxsiy",
"Status o'zgartirildi": "Status o'zgartirildi"
} }

11
stack.yaml Normal file
View File

@@ -0,0 +1,11 @@
version: "3.9"
services:
simple-travel-front-admin:
image: muhammadvadud/simple-travel-front-admin:latest
ports:
- "5263:3000"
deploy:
replicas: 2
restart_policy:
condition: on-failure

View File

@@ -19,6 +19,12 @@ export default defineConfig({
host: true, host: true,
port: 5173, port: 5173,
}, },
preview: {
host: true, // Production (vite preview) uchun
port: 5263,
allowedHosts: ["admin.simpletravel.uz"], // ✅ bu yer muhim
},
build: { build: {
outDir: "dist", // Vercel build chiqishini shu papkadan oladi outDir: "dist", // Vercel build chiqishini shu papkadan oladi
sourcemap: false, // Agar kerak bolmasa ochirib qoying sourcemap: false, // Agar kerak bolmasa ochirib qoying