24 |
25 | Delete {title}
26 |
27 |
28 | Are you sure you want to delete this {title.toLowerCase()}? This action cannot be undone.
29 |
30 |
31 |
37 | Cancel
38 |
39 |
45 | {isDeleting ? "Deleting..." : `Delete ${title}`}
46 |
47 |
48 |
49 | );
50 | }
--------------------------------------------------------------------------------
/frontend/src/components/molecules/auth-required-alert.tsx:
--------------------------------------------------------------------------------
1 | import { Alert, AlertTitle, AlertDescription } from "../atoms/alert";
2 | import { Button } from "../atoms/button";
3 | interface AuthRequiredAlertProps {
4 | description?: string;
5 | onClick: () => void;
6 | }
7 | export function AuthRequiredAlert({
8 | description = "create a listing.",
9 | onClick,
10 | }: AuthRequiredAlertProps) {
11 | return (
12 | setIsModalOpen(false)}
26 | />
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/buttons/login-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "../../atoms/button";
4 | import { User, LogOut } from "lucide-react";
5 | import { useUser } from "@/hooks/use-user";
6 |
7 | export function LoginButton() {
8 | const { user, login, logout } = useUser();
9 |
10 | return (
11 |
12 | {user ? (
13 |
19 |
20 | Logout
21 |
22 | ) : (
23 |
29 |
30 | Login
31 |
32 | )}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/buttons/message-button.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/atoms/button";
2 | import { MessageSquare } from "lucide-react";
3 |
4 | export function MessageButton() {
5 | return (
6 |
7 |
12 |
13 | Message
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/buttons/report-button.tsx:
--------------------------------------------------------------------------------
1 | import { Bug } from "lucide-react";
2 | import { Button } from "@/components/atoms/button";
3 | import { useTheme } from "@/context/ThemeProviderContext";
4 |
5 | export function ReportButton({
6 | className,
7 | text = "Report Bug",
8 | }: {
9 | className?: string;
10 | text?: string;
11 | }) {
12 | const { theme } = useTheme();
13 | const isDark = theme === "dark";
14 |
15 | return (
16 |
32 |
38 |
39 | {text}
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/buttons/submit-button.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/atoms/button";
2 | import { RefreshCw } from "lucide-react";
3 | interface SubmutButtonProps {
4 | isUploading: boolean;
5 | isTelegramLinked: boolean;
6 | uploadProgress: number;
7 | }
8 | export function SubmitButton({
9 | isUploading,
10 | isTelegramLinked,
11 | uploadProgress,
12 | }: SubmutButtonProps) {
13 | return (
14 |
19 | {isUploading ? (
20 |
21 |
22 | Uploading... {uploadProgress}%
23 |
24 | ) : (
25 | "Create Listing"
26 | )}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/combined-search.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { SearchInput } from "./search-input";
3 | import { ConditionDropdown } from "./condition-dropdown";
4 | import { PreSearchedItem } from "@/types/search";
5 |
6 | interface CombinedSearchProps {
7 | // Search props
8 | inputValue: string;
9 | setInputValue: (value: string) => void;
10 | preSearchedItems: PreSearchedItem[] | null;
11 | handleSearch: (query: string) => void;
12 | setKeyword: (keyword: string) => void;
13 |
14 | // Condition props
15 | conditions: string[];
16 | selectedCondition: string;
17 | setSelectedCondition: (condition: string) => void;
18 | }
19 |
20 | export function CombinedSearch({
21 | inputValue,
22 | setInputValue,
23 | preSearchedItems,
24 | handleSearch,
25 | setKeyword,
26 | conditions,
27 | selectedCondition,
28 | setSelectedCondition,
29 | }: CombinedSearchProps) {
30 | return (
31 |
32 |
37 |
45 |
46 | );
47 | }
--------------------------------------------------------------------------------
/frontend/src/components/molecules/general-section.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from "react-router-dom";
2 | import { Button } from "../atoms/button";
3 |
4 | export function GeneralSection({
5 | title,
6 | link,
7 | children,
8 | }: {
9 | title: string;
10 | link: string;
11 | children: React.ReactNode;
12 | }) {
13 | const navigate = useNavigate();
14 | return (
15 |
16 |
17 |
{title}
18 | navigate(link)}
22 | >
23 | See All
24 |
25 |
26 |
27 |
28 | {children}
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/hoc/with-suspense.tsx:
--------------------------------------------------------------------------------
1 | import { Spinner } from "@/components/atoms/spinner";
2 | import { ComponentType, Suspense } from "react";
3 |
4 | export const withSuspense =
5 | (Component: ComponentType
) =>
6 | (props: P) => {
7 | return (
8 | }>
9 |
10 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/login-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { Button } from "../../components/atoms/button";
5 | import { useUser } from "../../hooks/use-user";
6 | import { Modal } from "../../components/atoms/modal";
7 |
8 | interface LoginModalProps {
9 | isOpen: boolean;
10 | onClose: () => void;
11 | onSuccess: () => void;
12 | title: string;
13 | message: string;
14 | }
15 |
16 | export const LoginModal = ({
17 | isOpen,
18 | onClose,
19 | onSuccess,
20 | title,
21 | message,
22 | }: LoginModalProps) => {
23 | const { user, login } = useUser();
24 | const [isLoggingIn, setIsLoggingIn] = useState(false);
25 |
26 | const handleLogin = () => {
27 | setIsLoggingIn(true);
28 | login();
29 | // In a real app, we would wait for the login to complete
30 | // For now, we'll just simulate it
31 | setTimeout(() => {
32 | setIsLoggingIn(false);
33 | onSuccess();
34 | }, 1000);
35 | };
36 |
37 | return (
38 |
44 |
45 |
46 | Cancel
47 |
48 |
49 | {isLoggingIn ? "Logging in..." : "Login"}
50 |
51 |
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/telegram-status.tsx:
--------------------------------------------------------------------------------
1 | import { FaTelegram } from "react-icons/fa";
2 | import { Badge } from "@/components/atoms/badge";
3 | import { useTheme } from "@/context/ThemeProviderContext";
4 |
5 | interface TelegramStatusProps {
6 | isConnected: boolean;
7 | className?: string;
8 | }
9 |
10 | export function TelegramStatus({
11 | isConnected,
12 | className = "",
13 | }: TelegramStatusProps) {
14 | const { theme } = useTheme();
15 | const isDark = theme === "dark";
16 |
17 | if (!isConnected) return null;
18 |
19 | return (
20 |
36 |
37 | Connected
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Moon, Sun } from "lucide-react";
4 | import { useTheme } from "../../context/ThemeProviderContext";
5 | import { Switch } from "../atoms/switch";
6 |
7 | export function ThemeToggle() {
8 | const { theme, setTheme } = useTheme();
9 |
10 | const toggleTheme = () => {
11 | setTheme(theme === "light" ? "dark" : "light");
12 | };
13 |
14 | return (
15 |
16 |
23 |
29 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/about/about-header.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "@/context/ThemeProviderContext";
2 |
3 | export const AboutHeader = () => {
4 | const { theme } = useTheme();
5 | const isDark = theme === "dark";
6 | return (
7 |
8 |
9 | About Nuspace
10 |
11 |
16 | SuperApp for Nazarbayev University students
17 |
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/about/about-us-section.tsx:
--------------------------------------------------------------------------------
1 | import { ReportCard } from "@/components/organisms/about/report-card";
2 | import { TeamCard } from "@/components/organisms/about/team-card";
3 |
4 | export function AboutUsSection() {
5 | return (
6 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/about/feature-card.tsx:
--------------------------------------------------------------------------------
1 | import { IconType } from "react-icons/lib";
2 | import { Card } from "@/components/atoms/card";
3 | import { useTheme } from "@/context/ThemeProviderContext";
4 | interface FeatureCardProps {
5 | title: string;
6 | description: string;
7 | icon: IconType;
8 | iconSize?: number;
9 | iconColor?: string;
10 | }
11 |
12 | export const FeatureCard = ({
13 | title,
14 | description,
15 | icon: Icon,
16 | iconSize = 36,
17 | iconColor = "text-indigo-500",
18 | }: FeatureCardProps) => {
19 | const { theme } = useTheme();
20 | const isDark = theme === "dark";
21 | return (
22 |
27 |
28 |
29 |
30 | {title}
31 |
32 | {description}
33 |
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/about/mission-section.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "@/context/ThemeProviderContext";
2 |
3 | export const MessionSection = () => {
4 | const { theme } = useTheme();
5 | const isDark = theme === "dark";
6 | return (
7 |
12 |
Mission
13 |
14 | Nuspace is a single platform for Nazarbayev University students. Our
15 | goal is to simplify the daily life of students and make campus life more
16 | comfortable.
17 |
18 |
19 | We strive to create a reliable platform that will allow every student of
20 | Nazarbayev University to make the most of their time, easily find the
21 | necessary things and keep abreast of interesting events and
22 | opportunities within the university.
23 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/about/report-card.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from "@/components/atoms/card";
2 | import { useTheme } from "@/context/ThemeProviderContext";
3 | import { FaHeadset, FaTelegram } from "react-icons/fa";
4 |
5 | export function ReportCard() {
6 | const { theme } = useTheme();
7 | const isDark = theme === "dark";
8 | return (
9 |
14 |
15 |
16 |
17 |
18 | Need Help?
19 |
22 | Found a bug or having issues? Reach out directly
23 | for quick assistance.
24 |
25 |
29 |
30 | Contact via Telegram
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/animations/AnimatedCard.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import { Card } from "../../atoms/card";
3 | import { ReactNode } from "react";
4 |
5 | interface AnimatedCardProps {
6 | children: ReactNode;
7 | hasFloatingBackground?: boolean;
8 | backgroundEffects?: ReactNode;
9 | variants?: any;
10 | className?: string;
11 | [key: string]: any;
12 | }
13 |
14 | export function AnimatedCard({
15 | children,
16 | hasFloatingBackground = false,
17 | backgroundEffects,
18 | variants,
19 | className = "",
20 | ...props
21 | }: AnimatedCardProps) {
22 | return (
23 |
24 |
28 | {hasFloatingBackground && (
29 |
30 | {backgroundEffects}
31 |
32 | )}
33 |
34 | {children}
35 |
36 |
37 |
38 | );
39 | }
--------------------------------------------------------------------------------
/frontend/src/components/organisms/animations/AnimatedFormField.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import { Label } from "../../atoms/label";
3 | import { ReactNode } from "react";
4 |
5 | interface AnimatedFormFieldProps {
6 | label: string;
7 | icon: ReactNode;
8 | fieldName: string;
9 | isFocused: boolean;
10 | children: ReactNode;
11 | showFocusIndicator?: boolean;
12 | focusColor?: string;
13 | className?: string;
14 | }
15 |
16 | export function AnimatedFormField({
17 | label,
18 | icon,
19 | fieldName,
20 | isFocused,
21 | children,
22 | showFocusIndicator = true,
23 | focusColor = "primary",
24 | className = ""
25 | }: AnimatedFormFieldProps) {
26 | return (
27 |
28 |
29 |
30 | {icon}
31 |
32 |
33 | {label}
34 |
35 |
36 |
37 |
38 | {children}
39 |
40 |
41 | );
42 | }
--------------------------------------------------------------------------------
/frontend/src/components/organisms/category-grid.tsx:
--------------------------------------------------------------------------------
1 | import { CategoryCard } from "@/components/atoms/category-card";
2 |
3 | interface CategoryGridProps {
4 | categories: { title: string; icon?: JSX.Element }[];
5 | selectedCategory: string | "";
6 | setPage?: (page: number) => void;
7 | setSelectedCategory: (category: string) => void;
8 | setInputValue?: (value: string) => void;
9 | setSelectedCondition?: (condition: string) => void;
10 | onCategorySelect: (title: string) => void;
11 | }
12 |
13 | export function CategoryGrid({
14 | categories,
15 | selectedCategory,
16 | onCategorySelect,
17 | }: CategoryGridProps) {
18 | return (
19 |
20 | {/* True Grid Layout */}
21 |
22 | {categories.map((cat) => (
23 | onCategorySelect(cat.title)}
31 | />
32 | ))}
33 |
34 |
35 | );
36 | }
--------------------------------------------------------------------------------
/frontend/src/components/organisms/category-slider.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from "react-router-dom";
2 | import { CategoryGrid } from "./category-grid";
3 |
4 | interface CategorySliderProps {
5 | categories: { title: string; icon?: JSX.Element}[];
6 | selectedCategory: string | "";
7 | setPage?: (page: number) => void;
8 | setSelectedCategory: (category: string) => void;
9 | setInputValue?: (value: string) => void;
10 | setSelectedCondition?: (condition: string) => void;
11 | }
12 |
13 | export function CategorySlider({
14 | categories,
15 | selectedCategory,
16 | setPage,
17 | setSelectedCategory,
18 | setInputValue,
19 | setSelectedCondition,
20 | }: CategorySliderProps) {
21 | const navigate = useNavigate();
22 |
23 | const handleCategorySelect = (title: string) => {
24 | setSelectedCategory(title);
25 | setPage?.(1);
26 | setInputValue?.("");
27 | setSelectedCondition?.("All Conditions");
28 |
29 | navigate(`${window.location.pathname}?category=${title.toLowerCase()}`);
30 | };
31 |
32 | return (
33 | <>
34 | {categories?.length > 0 && (
35 |
44 | )}
45 | >
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/filter-container.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface FilterContainerProps {
4 | children: React.ReactNode;
5 | className?: string;
6 | title?: string;
7 | }
8 |
9 | export function FilterContainer({ children, className = "", title }: FilterContainerProps) {
10 | return (
11 |
20 | {title && (
21 |
22 | {title}
23 |
24 | )}
25 | {children}
26 |
27 | );
28 | }
--------------------------------------------------------------------------------
/frontend/src/components/organisms/media/index.ts:
--------------------------------------------------------------------------------
1 | // Core components
2 | export { MediaPreview, MediaPreviewDefaults } from './MediaPreview';
3 | export type { MediaPreviewProps } from './MediaPreview';
4 |
5 | // Re-export commonly used types for convenience
6 | export type { MediaItem, MediaAction } from '@/features/media/types/media';
7 |
--------------------------------------------------------------------------------
/frontend/src/components/templates/about-template.tsx:
--------------------------------------------------------------------------------
1 | import { AboutHeader } from "@/components/organisms/about/about-header";
2 | import { MessionSection } from "@/components/organisms/about/mission-section";
3 | import { AboutUsSection } from "@/components/organisms/about/about-us-section";
4 |
5 | export function AboutTemplate() {
6 | return (
7 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { ROUTES } from "@/data/routes";
3 |
4 | interface FooterProps {
5 | note: string;
6 | }
7 |
8 | export function Footer({ note }: FooterProps) {
9 | return (
10 |
11 |
12 | {note}
13 |
14 |
15 | );
16 | }
--------------------------------------------------------------------------------
/frontend/src/data/features.ts:
--------------------------------------------------------------------------------
1 | import { MdSell, MdEvent, MdRestaurantMenu } from "react-icons/md";
2 | import { ROUTES } from "./routes";
3 | export const features = [
4 | {
5 | title: "Kupi-Prodai",
6 | description:
7 | "Here students have the opportunity to sell, buy or exchange their belongings.",
8 | icon: MdSell,
9 | link: ROUTES.APPS.KUPI_PRODAI.ROOT,
10 | },
11 | {
12 | title: "Events",
13 | description:
14 | "Information about holidays, meetings and events that take place on the territory of the University. Students will be able to find activities that are interesting to them.",
15 | icon: MdEvent,
16 | link: ROUTES.APPS.CAMPUS_CURRENT.ROOT,
17 | },
18 | {
19 | title: "Dorm Eats",
20 | description:
21 | "Daily menu in the university canteen. What dishes are available, what dishes are being prepared - all this students have the opportunity to find out in advance.",
22 | icon: MdRestaurantMenu,
23 | link: ROUTES.APPS.DORM_EATS.ROOT,
24 | },
25 | ];
26 |
--------------------------------------------------------------------------------
/frontend/src/data/kp/product.tsx:
--------------------------------------------------------------------------------
1 | import { Blocks, Book, Cable, Shirt, Armchair, WashingMachine, Volleyball, Apple, Bike, Archive} from "lucide-react";
2 | export const productCategories: Types.DisplayCategory[] = [
3 | {
4 | title: "All",
5 | icon: Blocks,
6 | },
7 | {
8 | title: "Books",
9 | icon: Book,
10 | },
11 | {
12 | title: "Electronics",
13 | icon: Cable,
14 | },
15 | {
16 | title: "Clothing",
17 | icon: Shirt,
18 | },
19 | {
20 | title: "Furniture",
21 | icon: Armchair
22 | },
23 | {
24 | title: "Appliances",
25 | icon: WashingMachine
26 | },
27 | {
28 | title: "Sports",
29 | icon: Volleyball,
30 | },
31 | {
32 | title: "Food",
33 | icon: Apple,
34 | },
35 | {
36 | title: "Transport",
37 | icon: Bike,
38 | },
39 | {
40 | title: "Others",
41 | icon: Archive,
42 | },
43 | ];
44 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/communities/api/hooks/usePreSearchCommunities.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { apiCall } from "@/utils/api";
3 | import { PreSearchedItem } from "@/types/search";
4 |
5 | export const usePreSearchCommunities = (inputValue: string) => {
6 | const keyword = String(inputValue || "").trim();
7 | const { data } = useQuery({
8 | queryKey: ["pre-search-communities", keyword],
9 | enabled: !!keyword,
10 | queryFn: async ({ signal }) => {
11 | const res = await apiCall(
12 | `/search/?keyword=${encodeURIComponent(keyword)}&storage_name=communities&page=1&size=10`,
13 | { signal },
14 | );
15 | return res as Array<{ id: number | string; name: string }>;
16 | },
17 | });
18 |
19 | const preSearchedItems: PreSearchedItem[] | null = Array.isArray(data)
20 | ? data.map((c) => ({ id: c.id, name: c.name }))
21 | : null;
22 |
23 | return { preSearchedItems };
24 | };
25 |
26 |
27 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/communities/components/forms/CommunityDescription.tsx:
--------------------------------------------------------------------------------
1 | import { useCommunityForm } from "@/context/CommunityFormContext";
2 | import { Label } from "@/components/atoms/label";
3 | import { Textarea } from "@/components/atoms/textarea";
4 |
5 | export function CommunityDescription() {
6 | const { formData, handleInputChange, isFieldEditable } = useCommunityForm();
7 |
8 | return (
9 |
10 |
11 | Description *
12 |
13 | {(formData.description || "").length} / 1000
14 |
15 |
16 |
27 |
28 | );
29 | }
30 |
31 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/communities/hooks/use-communities.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/communitiesApi";
3 | import { useState, useEffect } from "react";
4 | import { usePageParam } from "@/hooks/usePageParam";
5 | import { Community } from "@/features/campuscurrent/types/types";
6 | import { useLocation } from "react-router-dom";
7 | import { getSearchParamFromURL } from "@/utils/search-params";
8 |
9 | export const useCommunities = (options?: { category?: string | null; recruitment_status?: 'open' | 'closed' | null }) => {
10 | const [page, setPage] = usePageParam();
11 | const [size, setSize] = useState(12);
12 | const [keyword, setKeyword] = useState("");
13 | const location = useLocation();
14 |
15 | useEffect(() => {
16 | const text = getSearchParamFromURL(location.search, "text");
17 | setKeyword(text);
18 | }, [location.search]);
19 |
20 | const { data, isLoading, isError } = useQuery>(
21 | campuscurrentAPI.getCommunitiesQueryOptions({
22 | page,
23 | size,
24 | keyword: keyword || null,
25 | category: options?.category ?? null,
26 | recruitment_status: options?.recruitment_status ?? null,
27 | }),
28 | );
29 |
30 | return {
31 | communities: data || null,
32 | isLoading,
33 | isError,
34 | page,
35 | setPage,
36 | size,
37 | setSize,
38 | keyword,
39 | setKeyword,
40 | };
41 | };
42 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/communities/hooks/use-community.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/communitiesApi";
3 | import { useParams } from "react-router-dom";
4 | import { Community, CommunityPermissions } from "@/features/campuscurrent/types/types";
5 |
6 | export const useCommunity = () => {
7 | const { id } = useParams<{ id: string }>();
8 |
9 | const { data, isPending, isLoading, isError } = useQuery({
10 | ...campuscurrentAPI.getCommunityQueryOptions(id || ""),
11 | enabled: !!id,
12 | // Normalize API response to a consistent shape
13 | select: (raw: any): { community: Community | null; permissions: CommunityPermissions | null } => {
14 | const community: Community | null = (raw?.community as Community) ?? (raw as Community) ?? null;
15 | const permissions: CommunityPermissions | null =
16 | (raw?.permissions as CommunityPermissions) ??
17 | ((community as any)?.permissions as CommunityPermissions) ??
18 | null;
19 | return { community, permissions };
20 | },
21 | });
22 |
23 | return {
24 | community: (data as any)?.community ?? null,
25 | permissions: (data as any)?.permissions ?? null,
26 | isPending,
27 | isLoading,
28 | isError,
29 | };
30 | };
31 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/communities/hooks/use-edit-community.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/communitiesApi";
3 | import { useParams } from "react-router-dom";
4 | import { Community } from "@/features/campuscurrent/types/types";
5 |
6 | export const useEditCommunity = () => {
7 | const { id } = useParams<{ id: string }>();
8 | const queryClient = useQueryClient();
9 |
10 | return useMutation({
11 | mutationFn: (data: Community) =>
12 | campuscurrentAPI.editCommunity(data.id.toString(), data),
13 | onSuccess: (data, variables) => {
14 | queryClient.invalidateQueries({
15 | queryKey: campuscurrentAPI.getCommunityQueryOptions(variables.id.toString()).queryKey,
16 | });
17 | queryClient.invalidateQueries({ queryKey: ['campusCurrent', 'community', id] });
18 | },
19 | });
20 | };
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/communities/hooks/use-search-communities.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/communitiesApi";
3 | import { Community } from "@/features/campuscurrent/types/types";
4 |
5 | export const useSearchCommunities = (params?: { keyword?: string; size?: number }) => {
6 | const keyword = (params?.keyword ?? "").trim();
7 | const size = params?.size ?? (keyword ? 10 : 20);
8 |
9 | const { data, isLoading, isError } = useQuery>(
10 | campuscurrentAPI.getCommunitiesQueryOptions({
11 | page: 1,
12 | size,
13 | keyword: keyword || null,
14 | category: null,
15 | })
16 | );
17 |
18 | return {
19 | communities: data || null,
20 | isLoading,
21 | isError,
22 | };
23 | };
24 |
25 |
26 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/communities/hooks/use-user-communities.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/communitiesApi";
3 |
4 | export const useUserCommunities = (userSub: string | null | undefined) => {
5 | const { data, isLoading, isError } = useQuery({
6 | ...campuscurrentAPI.getUserCommunitiesQueryOptions(userSub || ""),
7 | enabled: !!userSub,
8 | });
9 |
10 | return {
11 | communities: (data as any)?.communities || [],
12 | isLoading,
13 | isError,
14 | totalCommunities: (data as any)?.total_pages || 0,
15 | };
16 | };
17 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/communities/hooks/useDeleteCommunity.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "@/features/campuscurrent/communities/api/communitiesApi";
3 | import { useToast } from "@/hooks/use-toast";
4 |
5 | export function useDeleteCommunity() {
6 | const { toast } = useToast();
7 | const queryClient = useQueryClient();
8 |
9 | const deleteCommunityMutation = useMutation({
10 | mutationFn: (id: string) => campuscurrentAPI.deleteCommunity(id),
11 | onSuccess: () => {
12 | queryClient.invalidateQueries({ queryKey: ["campusCurrent", "communities"] });
13 | toast({
14 | title: "Success",
15 | description: "Community deleted successfully!",
16 | });
17 | },
18 | onError: (error) => {
19 | console.error("Community deletion failed:", error);
20 | toast({
21 | title: "Error",
22 | description: "Failed to delete community",
23 | variant: "destructive",
24 | });
25 | },
26 | });
27 |
28 | const handleDelete = async (id: string) => {
29 | try {
30 | await deleteCommunityMutation.mutateAsync(id);
31 | } catch (error) {
32 | console.error("Community deletion failed:", error);
33 | throw error;
34 | }
35 | };
36 |
37 | return {
38 | handleDelete,
39 | isDeleting: deleteCommunityMutation.isPending,
40 | };
41 | }
42 |
43 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/communities/hooks/useInfiniteCommunities.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteScroll, useInfiniteScrollWithWindow } from '@/hooks/useInfiniteScroll';
2 | import { Community } from '@/features/campuscurrent/types/types';
3 | import * as Routes from '@/data/routes';
4 |
5 | export type UseInfiniteCommunitiesParams = {
6 | keyword?: string;
7 | category?: string | null;
8 | recruitment_status?: 'open' | 'closed' | null;
9 | size?: number;
10 | };
11 |
12 | export function useInfiniteCommunities(params: UseInfiniteCommunitiesParams = {}) {
13 | const {
14 | keyword = "",
15 | category,
16 | recruitment_status,
17 | size = 12,
18 | } = params;
19 |
20 | const infiniteScrollReturn = useInfiniteScroll({
21 | queryKey: ["campusCurrent", "communities"],
22 | apiEndpoint: `/${Routes.COMMUNITIES}`,
23 | size,
24 | keyword,
25 | additionalParams: {
26 | category,
27 | recruitment_status,
28 | },
29 | });
30 |
31 | return useInfiniteScrollWithWindow(infiniteScrollReturn);
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/communities/hooks/useVirtualCommunities.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
2 | import { Community } from '@/features/campuscurrent/communities/types';
3 | import * as Routes from '@/data/routes';
4 |
5 | export function useVirtualCommunities(keyword: string = "") {
6 | return useInfiniteScroll({
7 | queryKey: ["campusCurrent", "communities"],
8 | apiEndpoint: `/${Routes.COMMUNITIES}`,
9 | size: 12,
10 | keyword,
11 | additionalParams: {},
12 | estimateSize: () => 200, // Estimate each community card to be 200px tall
13 | overscan: 4, // Only render 4 items outside viewport
14 | });
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/events/components/forms/EventDescription.tsx:
--------------------------------------------------------------------------------
1 | import { Label } from '@/components/atoms/label';
2 | import { Textarea } from '@/components/atoms/textarea';
3 | import { useEventForm } from '../../../../../context/EventFormContext';
4 |
5 | export function EventDescription() {
6 | const {
7 | formData,
8 | handleInputChange,
9 | isFieldEditable,
10 | } = useEventForm();
11 |
12 | return (
13 |
14 |
15 | Description
16 |
17 | {formData.description?.length} / 1250
18 |
19 |
20 |
31 |
32 | );
33 | }
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/events/hooks/useEvent.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/eventsApi";
3 | import { useParams } from "react-router-dom";
4 |
5 | export const useEvent = () => {
6 | const { id } = useParams<{ id: string }>();
7 | console.log("id", id);
8 | const {
9 | data: event,
10 | isPending,
11 | isLoading,
12 | isError,
13 | } = useQuery({
14 | ...campuscurrentAPI.getEventQueryOptions(id || ""),
15 | enabled: !!id,
16 | });
17 | return { event: event || null, isPending, isLoading, isError };
18 | };
19 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/events/hooks/useEvents.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI, TimeFilter } from "@/features/campuscurrent/events/api/eventsApi";
3 | import { useState } from "react";
4 | import { usePageParam } from "@/hooks/usePageParam";
5 |
6 | export type UseEventsParams = {
7 | time_filter?: TimeFilter;
8 | start_date?: string;
9 | end_date?: string;
10 | registration_policy?: string | null;
11 | event_scope?: string | null;
12 | event_type?: string | null;
13 | event_status?: string | null;
14 | community_id?: number | null;
15 | creator_sub?: string | null;
16 | keyword?: string | null;
17 | size?: number;
18 | };
19 |
20 | export const useEvents = (params: UseEventsParams) => {
21 | const [page, setPage] = usePageParam();
22 | const [size, setSize] = useState(params.size ?? 12);
23 | const [keyword, setKeyword] = useState("");
24 |
25 | const { data, isLoading, isError } = useQuery(
26 | campuscurrentAPI.getEventsQueryOptions({
27 | ...params,
28 | page,
29 | size,
30 | keyword: keyword || null,
31 | }),
32 | );
33 |
34 | return {
35 | events: data || null,
36 | isLoading,
37 | isError,
38 | page,
39 | setPage,
40 | size,
41 | setSize,
42 | keyword,
43 | setKeyword,
44 | };
45 | };
46 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/events/hooks/useVirtualEvents.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
2 | import { Event } from '@/features/campuscurrent/events/types';
3 | import * as Routes from '@/data/routes';
4 |
5 | export function useVirtualEvents(keyword: string = "") {
6 | return useInfiniteScroll({
7 | queryKey: ["campusCurrent", "events"],
8 | apiEndpoint: `/${Routes.EVENTS}`,
9 | size: 12,
10 | keyword,
11 | additionalParams: {},
12 | estimateSize: () => 250, // Estimate each event card to be 250px tall
13 | overscan: 4, // Only render 4 items outside viewport
14 | });
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/events/utils/calendar.ts:
--------------------------------------------------------------------------------
1 | import { Event } from "../../types/types";
2 |
3 | export const addToGoogleCalendar = (event: Event) => {
4 | const eventDate = new Date(event.start_datetime);
5 | const endDate = new Date(event.end_datetime);
6 |
7 | const googleCalendarUrl = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(
8 | event.name,
9 | )}&dates=${eventDate
10 | .toISOString()
11 | .replace(/-|:|\.\d+/g, "")}/${endDate
12 | .toISOString()
13 | .replace(/-|:|\.\d+/g, "")}&details=${encodeURIComponent(
14 | event.description,
15 | )}&location=${encodeURIComponent(event.place)}`;
16 |
17 | window.open(googleCalendarUrl, "_blank");
18 | };
19 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/pages/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "react-router-dom";
2 | import { useState } from "react";
3 | import { CommunityModal } from "@/features/campuscurrent/communities/components/CommunityModal";
4 | import { EventModal } from "@/features/campuscurrent/events/components/EventModal";
5 |
6 | export function Layout() {
7 | const [isCreateCommunityModalOpen, setIsCreateCommunityModalOpen] = useState(false);
8 | const [isCreateEventModalOpen, setIsCreateEventModalOpen] = useState(false);
9 |
10 |
11 |
12 |
13 | return (
14 |
15 | {/* Main Content - No tabs needed since only Events remains */}
16 |
17 |
18 |
19 |
20 | {/* Create Community Modal */}
21 |
setIsCreateCommunityModalOpen(false)}
24 | isEditMode={false}
25 | />
26 |
27 | {/* Create Event Modal */}
28 | setIsCreateEventModalOpen(false)}
31 | isEditMode={false}
32 | />
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/subspace/api/hooks/useCreatePost.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import { subspaceApi } from "@/features/campuscurrent/subspace/api/subspaceApi";
3 | import type { CreatePostData } from "@/features/campuscurrent/subspace/types";
4 | import { queryClient } from "@/utils/query-client";
5 |
6 | export function useCreatePost() {
7 | return useMutation({
8 | mutationFn: (data: CreatePostData) => subspaceApi.createPost(data),
9 | onSuccess: () => {
10 | queryClient.invalidateQueries({ queryKey: subspaceApi.baseKey });
11 | },
12 | });
13 | }
14 |
15 |
16 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/subspace/api/hooks/useDeletePost.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import { subspaceApi } from "@/features/campuscurrent/subspace/api/subspaceApi";
3 | import { queryClient } from "@/utils/query-client";
4 |
5 | export function useDeletePost() {
6 | return useMutation({
7 | mutationFn: (id: string | number) => subspaceApi.deletePost(id),
8 | onSuccess: () => {
9 | queryClient.invalidateQueries({ queryKey: subspaceApi.baseKey });
10 | },
11 | });
12 | }
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/subspace/api/hooks/usePost.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { subspaceApi } from "@/features/campuscurrent/subspace/api/subspaceApi";
3 |
4 | export function usePost(id: string | number) {
5 | const { data, isLoading, isError } = useQuery(
6 | subspaceApi.getPostQueryOptions(id),
7 | );
8 |
9 | return {
10 | post: data ?? null,
11 | isLoading,
12 | isError,
13 | };
14 | }
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/subspace/api/hooks/usePosts.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { usePageParam } from "@/hooks/usePageParam";
3 | import { useState } from "react";
4 | import { subspaceApi } from "@/features/campuscurrent/subspace/api/subspaceApi";
5 |
6 | export type UsePostsParams = {
7 | community_id?: number | null;
8 | size?: number;
9 | keyword?: string;
10 | };
11 |
12 | export function usePosts(params: UsePostsParams = {}) {
13 | const [page, setPage] = usePageParam();
14 | const [size, setSize] = useState(params.size ?? 12);
15 | const [internalKeyword, setInternalKeyword] = useState("");
16 |
17 | const keyword = params.keyword ?? internalKeyword;
18 |
19 | const { data, isLoading, isError } = useQuery(
20 | subspaceApi.getPostsQueryOptions({
21 | community_id: params.community_id ?? null,
22 | page,
23 | size,
24 | keyword: keyword || null,
25 | }),
26 | );
27 |
28 | return {
29 | posts: data ?? null,
30 | isLoading,
31 | isError,
32 | page,
33 | setPage,
34 | size,
35 | setSize,
36 | keyword,
37 | setKeyword: setInternalKeyword,
38 | };
39 | }
40 |
41 |
42 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/subspace/api/hooks/usePreSearchPosts.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { apiCall } from "@/utils/api";
3 | import { PreSearchedItem } from "@/types/search";
4 |
5 | export const usePreSearchPosts = (inputValue: string) => {
6 | const keyword = String(inputValue || "").trim();
7 | const { data } = useQuery({
8 | queryKey: ["pre-search-posts", keyword],
9 | enabled: !!keyword,
10 | queryFn: async ({ signal }) => {
11 | const res = await apiCall(
12 | `/search/?keyword=${encodeURIComponent(keyword)}&storage_name=community_posts&page=1&size=10`,
13 | { signal },
14 | );
15 | return res as Array<{ id: number | string; title: string }>;
16 | },
17 | });
18 |
19 | const preSearchedItems: PreSearchedItem[] | null = Array.isArray(data)
20 | ? data.map((p) => ({ id: p.id, name: p.title }))
21 | : null;
22 |
23 | return { preSearchedItems };
24 | };
25 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/subspace/api/hooks/useUpdatePost.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "@tanstack/react-query";
2 | import { subspaceApi } from "@/features/campuscurrent/subspace/api/subspaceApi";
3 | import type { UpdatePostData } from "@/features/campuscurrent/subspace/types";
4 | import { queryClient } from "@/utils/query-client";
5 |
6 | export function useUpdatePost() {
7 | return useMutation({
8 | mutationFn: ({ id, data }: { id: string | number; data: UpdatePostData }) => subspaceApi.updatePost(id, data),
9 | onSuccess: ({ id }) => {
10 | queryClient.invalidateQueries({ queryKey: subspaceApi.baseKey });
11 | queryClient.invalidateQueries({
12 | queryKey: [...subspaceApi.baseKey, "detail", String(id)] });
13 | },
14 | });
15 | }
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/subspace/hooks/useVirtualPosts.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
2 | import { SubspacePost } from '@/features/campuscurrent/subspace/types';
3 | import * as Routes from '@/data/routes';
4 |
5 | export function useVirtualPosts(keyword: string = "") {
6 | return useInfiniteScroll({
7 | queryKey: ["campusCurrent", "posts"],
8 | apiEndpoint: `/${Routes.POSTS}`,
9 | size: 10,
10 | keyword,
11 | additionalParams: { community_id: null },
12 | estimateSize: () => 300, // Estimate each post to be 300px tall
13 | overscan: 3, // Only render 3 items outside viewport
14 | });
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/subspace/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./api/subspaceApi";
2 | export * from "./types";
3 | export * from "./hooks/usePosts";
4 | export * from "./hooks/usePost";
5 | export * from "./hooks/useCreatePost";
6 | export * from "./hooks/useUpdatePost";
7 | export * from "./hooks/useDeletePost";
8 | export * from "./api/hooks/useCreatePost";
9 | export * from "./api/hooks/useUpdatePost";
10 | export * from "./api/hooks/useDeletePost";
11 |
12 |
13 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/subspace/pages/list.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { SubspacePosts } from "@/features/campuscurrent/subspace/components/SubspacePosts";
5 | import { SubspacePostModal } from "@/features/campuscurrent/subspace/components/SubspacePostModal";
6 | import MotionWrapper from "@/components/atoms/motion-wrapper";
7 |
8 | export default function SubspacePage() {
9 | const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
10 |
11 | return (
12 |
13 |
14 |
15 |
16 | setIsCreateModalOpen(true)}
18 | />
19 |
20 |
21 |
22 |
setIsCreateModalOpen(false)}
25 | />
26 |
27 |
28 | );
29 | }
30 |
31 |
32 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/types/event-sections.ts:
--------------------------------------------------------------------------------
1 | import { ROUTES } from "@/data/routes";
2 |
3 | export const eventSections = [
4 | {
5 | title: "Featured Events",
6 | description: "Handpicked events you won't want to miss.",
7 | link: ROUTES.APPS.CAMPUS_CURRENT.EVENTS,
8 | },
9 | {
10 | title: "Today's Events",
11 | description: "What's happening on campus today.",
12 | link: ROUTES.APPS.CAMPUS_CURRENT.EVENTS,
13 | },
14 | {
15 | title: "Academic",
16 | description: "Lectures, workshops, and academic deadlines.",
17 | link: ROUTES.APPS.CAMPUS_CURRENT.EVENTS,
18 | },
19 | {
20 | title: "Cultural",
21 | description: "Celebrate diversity and culture.",
22 | link: ROUTES.APPS.CAMPUS_CURRENT.EVENTS,
23 | },
24 | {
25 | title: "Sports",
26 | description: "Get in the game with university sports.",
27 | link: ROUTES.APPS.CAMPUS_CURRENT.EVENTS,
28 | },
29 | {
30 | title: "Social",
31 | description: "Meet new people and have fun.",
32 | link: ROUTES.APPS.CAMPUS_CURRENT.EVENTS,
33 | },
34 | ];
35 |
--------------------------------------------------------------------------------
/frontend/src/features/campuscurrent/types/nav-tabs.tsx:
--------------------------------------------------------------------------------
1 | import { ROUTES } from "@/data/routes";
2 |
3 | export const navTabs = [
4 | {
5 | name: "Events",
6 | path: ROUTES.APPS.CAMPUS_CURRENT.EVENTS,
7 | },
8 | ];
9 |
--------------------------------------------------------------------------------
/frontend/src/features/grade-statistics/api/hooks/usePreSearchGrades.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { apiCall } from "@/utils/api";
3 | import { PreSearchedItem } from "@/types/search";
4 |
5 | export const usePreSearchGrades = (inputValue: string) => {
6 | const keyword = String(inputValue || "").trim();
7 | const { data } = useQuery({
8 | queryKey: ["pre-search-grades", keyword],
9 | enabled: !!keyword,
10 | queryFn: async ({ signal }) => {
11 | const res = await apiCall(
12 | `/search/?keyword=${encodeURIComponent(keyword)}&storage_name=grade_reports&page=1&size=10`,
13 | { signal },
14 | );
15 | return res as Array<{ id: number | string; course_code: string; course_title: string }>;
16 | },
17 | });
18 |
19 | const preSearchedItems: PreSearchedItem[] | null = Array.isArray(data)
20 | ? data.map((grade) => ({
21 | id: grade.id,
22 | name: `${grade.course_code} - ${grade.course_title}`
23 | }))
24 | : null;
25 |
26 | return { preSearchedItems };
27 | };
28 |
--------------------------------------------------------------------------------
/frontend/src/features/grade-statistics/components/TrendIndicator.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowDown, ArrowUp } from 'lucide-react';
2 |
3 | interface TrendIndicatorProps {
4 | userScore: number;
5 | classAverage: number | null | undefined;
6 | }
7 |
8 | export function TrendIndicator({ userScore, classAverage }: TrendIndicatorProps) {
9 | if (classAverage == null) {
10 | return null;
11 | }
12 |
13 | const difference = userScore - classAverage;
14 | const isUp = difference >= 0;
15 | const colorClass = isUp ? 'text-green-500' : 'text-red-500';
16 | const Icon = isUp ? ArrowUp : ArrowDown;
17 |
18 | return (
19 |
20 |
21 |
22 | {Math.abs(difference).toFixed(1)}% {isUp ? 'above' : 'below'} avg
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/api/hooks/usePreSearchProducts.ts:
--------------------------------------------------------------------------------
1 | import { useUser } from "@/hooks/use-user";
2 | import { kupiProdaiApi } from "@/features/kupi-prodai/api/kupiProdaiApi";
3 | import { useQuery } from "@tanstack/react-query";
4 | import { PreSearchedItem } from "@/types/search";
5 |
6 | export const usePreSearchProducts = (inputValue: string) => {
7 | const { user } = useUser();
8 | const { data } = useQuery({
9 | ...kupiProdaiApi.getPreSearchedProductsQueryOptions(inputValue),
10 | enabled: !!user && !!inputValue,
11 | });
12 | const preSearchedItems: PreSearchedItem[] | null = Array.isArray(data)
13 | ? data.map((p) => ({ id: p.id, name: p.name }))
14 | : null;
15 | return { preSearchedItems };
16 | };
17 |
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/api/hooks/useProduct.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { kupiProdaiApi } from "../kupiProdaiApi";
3 | import { useParams } from "react-router-dom";
4 |
5 | export const useProduct = () => {
6 | const { id } = useParams<{ id: string }>();
7 |
8 | const {
9 | data: product,
10 | isLoading,
11 | isError,
12 | } = useQuery({
13 | ...kupiProdaiApi.getProductQueryOptions(id || ""),
14 | enabled: !!id,
15 | });
16 |
17 | return { product: product || null, isLoading, isError };
18 | };
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/api/hooks/useUserProducts.ts:
--------------------------------------------------------------------------------
1 | import { useUser } from "@/hooks/use-user";
2 | import { kupiProdaiApi } from "@/features/kupi-prodai/api/kupiProdaiApi";
3 | import { useQuery } from "@tanstack/react-query";
4 |
5 | export function useUserProducts() {
6 | const { user } = useUser();
7 | const {
8 | data: myProducts,
9 | isError,
10 | isLoading,
11 | } = useQuery({
12 | ...kupiProdaiApi.getUserProductsQueryOptions(),
13 | enabled: !!user,
14 | staleTime: Infinity,
15 | gcTime: 1000 * 60 * 60 * 24,
16 | });
17 |
18 | return { myProducts, isError, isLoading };
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/components/auth/AuthenticationGuard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useUser } from "@/hooks/use-user";
3 | import { LoginPromptCard } from "./LoginPromptCard";
4 | import { TelegramPromptCard } from "./TelegramPromptCard";
5 |
6 | interface AuthenticationGuardProps {
7 | children: React.ReactNode;
8 | }
9 |
10 | export function AuthenticationGuard({ children }: AuthenticationGuardProps) {
11 | const { user, login } = useUser();
12 | const isTelegramLinked = user?.tg_id || false;
13 |
14 | // Not logged in - show login prompt
15 | if (!user) {
16 | return ;
17 | }
18 |
19 | // Logged in but no Telegram - show telegram prompt
20 | if (!isTelegramLinked) {
21 | return ;
22 | }
23 |
24 | // Fully authenticated - render children
25 | return <>{children}>;
26 | }
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/components/auth/LoginPromptCard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/atoms/card";
3 | import { Button } from "@/components/atoms/button";
4 | import { User } from "lucide-react";
5 |
6 | interface LoginPromptCardProps {
7 | onLogin: () => void;
8 | }
9 |
10 | export function LoginPromptCard({ onLogin }: LoginPromptCardProps) {
11 | return (
12 |
15 |
16 |
17 | Ready to Sell?
18 |
19 |
20 | Join our marketplace and start selling your items today
21 |
22 |
23 |
24 |
25 |
30 | Login to Start Selling
31 |
32 |
33 |
34 |
35 | );
36 | }
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/components/auth/TelegramPromptCard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Card,CardDescription, CardHeader, CardTitle } from "@/components/atoms/card";
4 | import { MessageCircle } from "lucide-react";
5 |
6 |
7 | export function TelegramPromptCard() {
8 | return (
9 |
12 |
13 |
14 |
15 | Connect Telegram
16 |
17 |
18 |
19 |
20 | Link your Telegram account to enable secure communication with buyers.
21 | This helps buyers contact you directly and securely about your listings
22 |
23 |
24 |
25 |
26 | );
27 | }
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/components/main-page/SellSection.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { ProductCreateForm } from "@/features/kupi-prodai/components/product-create-form";
3 | import { useCreateProduct } from "@/features/kupi-prodai/api/hooks/useCreateProduct";
4 | import { useListingState } from "@/context/ListingContext";
5 | import { motion } from "framer-motion";
6 | import { FloatingElements } from "@/components/organisms/animations/FloatingElements";
7 | import { AuthenticationGuard } from "@/features/kupi-prodai/components/auth/AuthenticationGuard";
8 | import { containerVariants, itemVariants } from "@/utils/animationVariants";
9 |
10 | export function SellSection() {
11 | const { handleCreate } = useCreateProduct();
12 | const { uploadProgress } = useListingState();
13 |
14 |
15 |
16 | return (
17 |
25 | );
26 | }
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/components/main-page/my-listings/EmptyState.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Button } from '@/components/atoms/button';
3 | import { cn } from '@/utils/utils';
4 |
5 | interface EmptyStateProps {
6 | icon: React.ReactNode;
7 | title: string;
8 | description: string;
9 | buttonText?: string;
10 | onButtonClick?: () => void;
11 | className?: string;
12 | }
13 |
14 | export const EmptyState: React.FC = ({
15 | icon,
16 | title,
17 | description,
18 | buttonText,
19 | onButtonClick,
20 | className,
21 | }) => {
22 | return (
23 |
29 |
{icon}
30 |
{title}
31 |
{description}
32 | {buttonText && onButtonClick && (
33 |
{buttonText}
34 | )}
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/components/product-detail-page/ContactSellerModal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Modal } from "@/components/atoms/modal";
4 | import { Button } from "@/components/atoms/button";
5 | import { ExternalLink } from "lucide-react";
6 |
7 | interface ContactSellerModalProps {
8 | isOpen: boolean;
9 | onClose: () => void;
10 | telegramLink: string;
11 | }
12 |
13 | export function ContactSellerModal({ isOpen, onClose, telegramLink }: ContactSellerModalProps) {
14 | return (
15 |
21 |
22 |
23 | You will be redirected to Telegram to chat with the seller about
24 | this item
25 |
26 |
27 |
{
30 | window.open(telegramLink, "_blank");
31 | onClose();
32 | }}
33 | >
34 |
35 | Open in Telegram
36 |
37 |
38 |
39 | );
40 | }
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/components/state/product-empy-state.tsx:
--------------------------------------------------------------------------------
1 | export function ProductEmptyState() {
2 | return (
3 |
4 |
No products found.
5 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/components/state/product-error-state.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/atoms/button";
2 |
3 | export function ProductErrorState({ error }: { error: string }) {
4 | return (
5 |
6 |
{error}
7 |
12 | Try Again
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/features/kupi-prodai/components/state/product-loading-state.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from "@/components/atoms/card";
2 | import { Skeleton } from "@/components/atoms/skeleton";
3 |
4 | export function ProductLoadingState({ count = 8 }: { count?: number }) {
5 | return (
6 | <>
7 |
8 | {Array.from({ length: count }).map((_, index) => (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | ))}
23 |
24 | >
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/features/media/index.ts:
--------------------------------------------------------------------------------
1 | // Unified Media System - Main Export File
2 |
3 | // Context
4 | export {
5 | UnifiedMediaProvider,
6 | useUnifiedMediaContext,
7 | type MediaConfig,
8 | type MediaState,
9 | type MediaActions,
10 | type UnifiedMediaContextType
11 | } from './context/UnifiedMediaContext';
12 |
13 | // Hooks
14 | export {
15 | useUnifiedMedia,
16 | type UnifiedMediaHookReturn
17 | } from './hooks/useUnifiedMedia';
18 |
19 | // Components
20 | export {
21 | UnifiedMediaUploadZone,
22 | type UnifiedMediaUploadZoneProps
23 | } from '@/components/organisms/media/UnifiedMediaUploadZone';
24 |
25 | // Configuration
26 | export {
27 | MEDIA_CONFIGS,
28 | getMediaConfig,
29 | createCustomMediaConfig,
30 | type MediaConfigKey
31 | } from './config/mediaConfigs';
32 |
33 | // Feature-specific components
34 | export { UnifiedEventMediaUpload } from '@/features/campuscurrent/events/components/UnifiedEventMediaUpload';
35 | export { UnifiedProductMediaUpload } from '@/features/kupi-prodai/components/forms/UnifiedProductMediaUpload';
36 |
37 | // Legacy exports for backward compatibility (deprecated)
38 | export { useMediaUpload } from './hooks/useMediaUpload';
39 | export { useMediaSelection } from './hooks/useMediaSelection';
40 | export { useMediaEdit } from './hooks/useMediaEdit';
41 |
42 | // Types
43 | export type { UploadMediaOptions } from './types/types';
44 |
--------------------------------------------------------------------------------
/frontend/src/features/media/types/media.ts:
--------------------------------------------------------------------------------
1 | // src/features/media/types/media.ts
2 |
3 | export interface MediaItem {
4 | id?: number | string;
5 | url: string;
6 | name?: string;
7 | size?: number;
8 | isMain?: boolean;
9 | type?: 'image' | 'video' | 'document';
10 | }
11 |
12 | export interface MediaAction {
13 | id: string;
14 | label: string;
15 | icon: React.ComponentType<{ className?: string }>;
16 | onClick: (index: number, item: MediaItem) => void;
17 | variant?: 'default' | 'destructive';
18 | showInHover?: boolean;
19 | showInDropdown?: boolean;
20 | }
21 |
22 |
--------------------------------------------------------------------------------
/frontend/src/features/media/types/types.ts:
--------------------------------------------------------------------------------
1 | export interface Media {
2 | id: number;
3 | url: string;
4 | mime_type: string;
5 | entity_type: EntityType;
6 | entity_id: number;
7 | media_format: MediaFormat;
8 | media_order: number;
9 | }
10 |
11 |
12 | export enum MediaFormat {
13 | banner = "banner",
14 | carousel = "carousel",
15 | profile = "profile"
16 | }
17 |
18 | export enum EntityType {
19 | products = "products",
20 | community_events = "community_events",
21 | communities = "communities",
22 | community_posts = "community_posts",
23 | reviews = "reviews",
24 | community_comments = "community_comments"
25 | }
26 |
27 |
28 | export interface SignedUrlRequest {
29 | entity_type: EntityType;
30 | entity_id: number;
31 | media_format: MediaFormat;
32 | media_order: number;
33 | mime_type: string;
34 | content_type: string;
35 | }
36 |
37 | export interface SignedUrlResponse {
38 | filename: string;
39 | upload_url: string;
40 | entity_type: EntityType;
41 | entity_id: number;
42 | media_format: MediaFormat;
43 | media_order: number;
44 | mime_type: string;
45 | }
46 |
47 |
48 | export interface UploadMediaOptions {
49 | entity_type: EntityType;
50 | entityId: number;
51 | mediaFormat: MediaFormat;
52 | startOrder?: number;
53 | }
--------------------------------------------------------------------------------
/frontend/src/features/media/utils/get-signed-urls.ts:
--------------------------------------------------------------------------------
1 | import { UploadMediaOptions } from "../types/types";
2 | import { SignedUrlRequest, SignedUrlResponse } from "../types/types";
3 | import { mediaApi } from "../api/mediaApi";
4 |
5 | export const getSignedUrls = async (
6 | entityId: number,
7 | files: File[],
8 | options: Omit,
9 | ): Promise => {
10 | const requests: SignedUrlRequest[] = files.map((file, idx) => ({
11 | entity_type: options.entity_type,
12 | entity_id: entityId,
13 | media_format: options.mediaFormat,
14 | media_order: (options.startOrder || 0) + idx,
15 | mime_type: file.type,
16 | content_type: file.type,
17 | }));
18 |
19 | return await mediaApi.getSignedUrls(requests);
20 | };
--------------------------------------------------------------------------------
/frontend/src/features/media/utils/upload-media.ts:
--------------------------------------------------------------------------------
1 | export const uploadMedia = async (files: File[], signedUrls: any[]) => {
2 | return Promise.all(
3 | files.map((file: File, i: number) => {
4 | const {
5 | upload_url,
6 | filename,
7 | entity_type,
8 | entity_id,
9 | media_format,
10 | media_order,
11 | mime_type,
12 | } = signedUrls[i];
13 |
14 | const headers: Record = {
15 | "x-goog-meta-filename": filename,
16 | "x-goog-meta-media-table": entity_type,
17 | "x-goog-meta-entity-id": entity_id.toString(),
18 | "x-goog-meta-media-format": media_format,
19 | "x-goog-meta-media-order": media_order.toString(),
20 | "x-goog-meta-mime-type": mime_type,
21 | "Content-Type": mime_type,
22 | };
23 |
24 | return fetch(upload_url, {
25 | method: "PUT",
26 | headers,
27 | body: file,
28 | });
29 | }),
30 | );
31 | };
--------------------------------------------------------------------------------
/frontend/src/features/sgotinish/components/CreateAppealButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/atoms/button";
2 | import { FileText } from "lucide-react";
3 |
4 | interface CreateAppealButtonProps {
5 | onClick: () => void;
6 | }
7 |
8 | export function CreateAppealButton({ onClick }: CreateAppealButtonProps) {
9 | return (
10 |
14 |
15 | Create Appeal
16 | Create
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/src/features/sgotinish/utils/date.ts:
--------------------------------------------------------------------------------
1 | const ensureUtcString = (value: string): string => {
2 | // If the string already has timezone info (Z or +/-HH:MM), keep it
3 | if (/([zZ]|[+-]\d{2}:?\d{2})$/.test(value)) {
4 | return value;
5 | }
6 |
7 | // Otherwise assume backend sent UTC without suffix, so append Z
8 | return `${value}Z`;
9 | };
10 |
11 | export const toLocalDate = (value: Date | string): Date => {
12 | if (value instanceof Date) {
13 | return value;
14 | }
15 |
16 | return new Date(ensureUtcString(value));
17 | };
18 |
19 | export const toLocalISOString = (value: Date | string): string => {
20 | return toLocalDate(value).toLocaleString();
21 | };
22 |
23 |
24 |
--------------------------------------------------------------------------------
/frontend/src/features/sgotinish/utils/roleMapping.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Maps backend role names to display names
3 | */
4 | export const mapRoleToDisplayName = (role: string): string => {
5 | switch (role) {
6 | case "boss":
7 | return "head";
8 | case "capo":
9 | return "executive";
10 | case "soldier":
11 | return "member";
12 | default:
13 | return role;
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useDebounce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 |
3 | export function useDebounce(value: T, delay: number): T {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 | const isFirstRender = useRef(true);
6 |
7 | useEffect(() => {
8 | if (isFirstRender.current) {
9 | isFirstRender.current = false;
10 | setDebouncedValue(value);
11 | return;
12 | }
13 |
14 | const timer = window.setTimeout(() => {
15 | setDebouncedValue(value);
16 | }, delay);
17 |
18 | return () => {
19 | window.clearTimeout(timer);
20 | };
21 | }, [value, delay]);
22 |
23 | return debouncedValue;
24 | }
25 |
26 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useFormAnimations.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | export function useFormAnimations() {
4 | const [focusedField, setFocusedField] = useState(null);
5 |
6 | const handleFieldFocus = (fieldName: string) => {
7 | setFocusedField(fieldName);
8 | };
9 |
10 | const handleFieldBlur = () => {
11 | setFocusedField(null);
12 | };
13 |
14 | const isFieldFocused = (fieldName: string) => {
15 | return focusedField === fieldName;
16 | };
17 |
18 | return {
19 | focusedField,
20 | handleFieldFocus,
21 | handleFieldBlur,
22 | isFieldFocused,
23 | };
24 | }
--------------------------------------------------------------------------------
/frontend/src/hooks/useGlobalSecondTicker.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | // Module-level singleton ticker to avoid multiple intervals across components
4 | let subscribers = new Set<() => void>();
5 | let intervalId: number | null = null;
6 |
7 | function notifyAll(): void {
8 | for (const subscriber of subscribers) {
9 | subscriber();
10 | }
11 | }
12 |
13 | function ensureRunning(): void {
14 | if (intervalId !== null) return;
15 | intervalId = window.setInterval(() => {
16 | notifyAll();
17 | }, 1000);
18 | }
19 |
20 | function ensureStopped(): void {
21 | if (intervalId === null) return;
22 | if (subscribers.size > 0) return;
23 | window.clearInterval(intervalId);
24 | intervalId = null;
25 | }
26 |
27 | export function useGlobalSecondTicker(): number {
28 | const [nowMs, setNowMs] = useState(Date.now());
29 |
30 | useEffect(() => {
31 | const handleTick = () => setNowMs(Date.now());
32 | subscribers.add(handleTick);
33 |
34 | // Start ticker if not running
35 | ensureRunning();
36 |
37 | // Emit an immediate tick so consumers get up-to-date value right away
38 | handleTick();
39 |
40 | return () => {
41 | subscribers.delete(handleTick);
42 | ensureStopped();
43 | };
44 | }, []);
45 |
46 | return nowMs;
47 | }
48 |
--------------------------------------------------------------------------------
/frontend/src/pages/about.tsx:
--------------------------------------------------------------------------------
1 | import { AboutTemplate } from "@/components/templates/about-template";
2 |
3 | export default function AboutPage() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/pages/apps/emergency.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { EmergencyInfoSection } from "@/components/organisms/emergency-info-section";
4 |
5 | export default function EmergencyPage() {
6 | return (
7 |
8 |
9 |
10 | );
11 | }
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/pages/profile.tsx:
--------------------------------------------------------------------------------
1 | export { default } from "@/features/campuscurrent/pages/profile";
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/frontend/src/types/images.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.svg" {
2 | const src: string;
3 | export default src;
4 | }
5 |
6 | declare module "*.png" {
7 | const src: string;
8 | export default src;
9 | }
10 |
11 | declare module "*.jpg" {
12 | const src: string;
13 | export default src;
14 | }
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/types/search.ts:
--------------------------------------------------------------------------------
1 | export interface PreSearchedItem {
2 | id: number | string;
3 | name: string;
4 | }
5 |
6 | export type SearchInputProps = {
7 | inputValue: string;
8 | setInputValue: (value: string) => void;
9 | preSearchedItems: PreSearchedItem[] | null;
10 | handleSearch: (inputValue: string) => void;
11 | setKeyword: (keyword: string) => void;
12 | // Optional, used in some contexts (e.g., products) to reset a secondary filter
13 | setSelectedCondition?: (condition: string) => void;
14 | placeholder?: string;
15 | };
16 |
17 |
18 |
--------------------------------------------------------------------------------
/frontend/src/utils/animationVariants.ts:
--------------------------------------------------------------------------------
1 | export const containerVariants = {
2 | hidden: { opacity: 0, y: 20 },
3 | visible: {
4 | opacity: 1,
5 | y: 0,
6 | transition: {
7 | type: "spring" as const,
8 | stiffness: 100,
9 | damping: 15,
10 | staggerChildren: 0.1
11 | }
12 | }
13 | };
14 |
15 | export const itemVariants = {
16 | hidden: { opacity: 0, y: 10 },
17 | visible: {
18 | opacity: 1,
19 | y: 0,
20 | transition: {
21 | type: "spring" as const,
22 | stiffness: 100,
23 | damping: 15
24 | }
25 | }
26 | };
27 |
28 | export const fieldVariants = {
29 | hidden: { opacity: 0, y: 10 },
30 | visible: {
31 | opacity: 1,
32 | y: 0,
33 | transition: {
34 | type: "spring" as const,
35 | stiffness: 100,
36 | damping: 15
37 | }
38 | }
39 | };
40 |
41 | export const formVariants = {
42 | hidden: { opacity: 0, y: 20 },
43 | visible: {
44 | opacity: 1,
45 | y: 0,
46 | transition: {
47 | type: "spring" as const,
48 | stiffness: 100,
49 | damping: 15,
50 | staggerChildren: 0.1
51 | }
52 | }
53 | };
54 |
55 | export const sectionVariants = {
56 | hidden: { opacity: 0, y: 10 },
57 | visible: {
58 | opacity: 1,
59 | y: 0,
60 | transition: {
61 | type: "spring" as const,
62 | stiffness: 100,
63 | damping: 15
64 | }
65 | }
66 | };
--------------------------------------------------------------------------------
/frontend/src/utils/image-utils.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Gets a placeholder image URL with cache busting
3 | * @param width Width of the image
4 | * @param height Height of the image
5 | * @returns A placeholder image URL
6 | */
7 | export function getPlaceholderImage(width = 200, height = 200): string {
8 | // Add a timestamp to prevent caching
9 | const timestamp = new Date().getTime();
10 |
11 | // Universal placeholder for all products
12 | return `https://placehold.co/${width}x${height}/EEE/31343C?text=No+Image&_t=${timestamp}`;
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/utils/products-utils.ts:
--------------------------------------------------------------------------------
1 | import { Product } from "@/features/kupi-prodai/types";
2 |
3 | export const getConditionColor = (condition: Product["condition"]) => {
4 | switch (condition) {
5 | case "new":
6 | return "bg-green-500";
7 | case "used":
8 | return "bg-orange-500";
9 | default:
10 | return "bg-gray-500";
11 | }
12 | };
13 | export const getConditionDisplay = (condition: string) => {
14 | switch (condition) {
15 | case "new":
16 | return "New";
17 | case "like_new":
18 | return "Like New";
19 | case "used":
20 | return "Used";
21 | default:
22 | return condition;
23 | }
24 | };
25 | export const getPlaceholderImage = (product: Product) => {
26 | return product.media[0]?.url || "https://placehold.co/200x200?text=No+Image";
27 | };
28 | export const getCategoryDisplay = (category: string) => {
29 | return (
30 | category.charAt(0).toUpperCase() + category.slice(1).replace(/_/g, " ")
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/frontend/src/utils/query-client.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient } from "@tanstack/react-query";
2 |
3 | export const queryClient = new QueryClient({
4 | defaultOptions: {
5 | queries: {
6 | staleTime: 1000 * 60 * 10,
7 | retry: false,
8 | refetchOnWindowFocus: false,
9 | },
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/frontend/src/utils/search-params.ts:
--------------------------------------------------------------------------------
1 | import { defaultPage } from "@/features/kupi-prodai/api/kupiProdaiApi";
2 |
3 | export const getSearchTextFromURL = (query: string) => {
4 | const params = new URLSearchParams(query);
5 | return params.get("text") || "";
6 | };
7 |
8 | export const getSearchParamFromURL = (
9 | query: string,
10 | key: string = "text",
11 | ): string => {
12 | const params = new URLSearchParams(query);
13 | return params.get(key) || "";
14 | };
15 |
16 | export const getSeachPageFromURL = (query: string) => {
17 | const params = new URLSearchParams(query);
18 | return Number(params.get("page")) || defaultPage;
19 | };
20 |
21 | export const getSearchCategoryFromURL = (query: string) => {
22 | const params = new URLSearchParams(query);
23 | return params.get("category") || "All";
24 | };
25 | export const getSearchConditionFromURL = (query: string) => {
26 | const params = new URLSearchParams(query);
27 | return params.get("condition") || "All Conditions";
28 | };
29 |
30 | export const getProductIdFromURL = (query: string) => {
31 | const params = new URLSearchParams(query);
32 | console.log(params.get("id"));
33 | };
34 |
--------------------------------------------------------------------------------
/frontend/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 |
23 | /* Paths */
24 | "baseUrl": ".",
25 | "paths": {
26 | "@/*": ["./src/*"]
27 | }
28 | },
29 | "include": ["src", "src/types"],
30 | "references": [{ "path": "./tsconfig.node.json" }]
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/infra/build.docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | fastapi:
3 | image: kamikadze24/fastapi:${TAG}
4 |
5 | build:
6 | context: ..
7 | dockerfile: backend/Dockerfile
8 | args:
9 | IS_DEBUG: ${IS_DEBUG:-true} # Default to 'true' if not set
10 |
11 | celeryworker:
12 | image: kamikadze24/celeryworker:${TAG}
13 | build:
14 | context: ..
15 | dockerfile: backend/Dockerfile_celery
16 | command: celery -A backend.celery_app.celery_config worker --loglevel=info -Q kick_queue,default
17 | environment:
18 | - CELERY_WORKER_CONCURRENCY=4
19 | - CELERY_WORKER_PREFETCH_MULTIPLIER=1
20 |
21 | frontend-builder:
22 | image: kamikadze24/frontendbuilder:${TAG}
23 | build:
24 | context: ..
25 | dockerfile: frontend/Dockerfile_static_builder
26 | command: sh -c "npm run build" # Run build command on container start
27 | restart: "no"
28 |
--------------------------------------------------------------------------------
/infra/grafana/provisioning/alerting/alerting.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | providers:
4 | - name: "default"
5 | type: file
6 | options:
7 | path: /etc/grafana/provisioning/alerting/alerts.yaml
8 |
--------------------------------------------------------------------------------
/infra/grafana/provisioning/alerting/contact-points.yaml.tpl:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | contactPoints:
4 | - orgId: 1
5 | name: Telegram
6 | receivers:
7 | - uid: telegram
8 | type: telegram
9 | disableResolveMessage: false
10 | allowedit: true
11 | settings:
12 | bottoken: '${TELEGRAM_BOT_TOKEN}'
13 | chatid: '${TELEGRAM_CHAT_ID}'
14 | message: |
15 | {{ template "default.message" . }}
--------------------------------------------------------------------------------
/infra/grafana/provisioning/dashboards/default.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | providers:
4 | - name: Default
5 | type: file
6 | options:
7 | path: /var/lib/grafana/dashboards
8 |
--------------------------------------------------------------------------------
/infra/grafana/provisioning/datasources/datasources.yml:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | datasources:
4 | - name: Prometheus
5 | type: prometheus
6 | url: http://prometheus:9090/prometheus/
7 | access: proxy
8 |
9 | - name: Loki
10 | type: loki
11 | url: http://loki:3100
12 | access: proxy
13 |
--------------------------------------------------------------------------------
/infra/nginx/vpn-index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | VPN Portal
6 |
7 |
37 |
38 |
39 |
40 |
VPN Portal
41 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/infra/pgadmin/pgpass:
--------------------------------------------------------------------------------
1 | postgres:5432:postgres:postgres:123
2 |
--------------------------------------------------------------------------------
/infra/pgadmin/servers.json:
--------------------------------------------------------------------------------
1 | {
2 | "Servers": {
3 | "1": {
4 | "Name": "PostgreSQL",
5 | "Group": "Servers",
6 | "Host": "postgres",
7 | "Port": 5432,
8 | "MaintenanceDB": "postgres",
9 | "Username": "postgres",
10 | "PassFile": "/var/lib/pgadmin/pgpass",
11 | "SSLMode": "prefer",
12 | "ConnectNow": true
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/infra/prometheus/alertmanager.yml.tpl:
--------------------------------------------------------------------------------
1 | global:
2 | resolve_timeout: 5m
3 |
4 | route:
5 | receiver: 'telegram'
6 |
7 | receivers:
8 | - name: 'telegram'
9 | telegram_configs:
10 | - bot_token: '${TELEGRAM_BOT_TOKEN}'
11 | chat_id: ${TELEGRAM_CHAT_ID}
12 | message: |
13 | [{{ .Status | toUpper }}] {{ .CommonAnnotations.summary }}
14 | {{ range .Alerts }}
15 | Description: {{ .Annotations.description }}
16 | {{ end }}
--------------------------------------------------------------------------------
/infra/prometheus/grafana_alert_rules.yml:
--------------------------------------------------------------------------------
1 | groups:
2 | - name: GrafanaDown
3 | rules:
4 | - alert: GrafanaDown
5 | expr: up{job="grafana"} == 0
6 | for: 40m
7 | labels:
8 | severity: critical
9 | annotations:
10 | summary: "Графана не доступна"
11 | description: "@sagyzdop Графана не доступна более 40 минут"
12 |
--------------------------------------------------------------------------------
/infra/prometheus/prometheus.yml:
--------------------------------------------------------------------------------
1 | global:
2 | scrape_interval: 15s
3 |
4 | scrape_configs:
5 | - job_name: grafana
6 | honor_timestamps: true
7 | scrape_interval: 15s
8 | scrape_timeout: 10s
9 | metrics_path: /metrics
10 | scheme: http
11 | follow_redirects: true
12 | static_configs:
13 | - targets:
14 | - grafana:3000
15 | metric_relabel_configs:
16 | - source_labels: [__name__]
17 | action: keep
18 | regex: "(up)"
19 |
20 | alerting:
21 | alertmanagers:
22 | - static_configs:
23 | - targets: ["alertmanager:9093"]
24 |
25 | rule_files:
26 | - "grafana_alert_rules.yml"
27 |
--------------------------------------------------------------------------------
/infra/rabbitmq/rabbitmq.conf:
--------------------------------------------------------------------------------
1 | log.console = true
2 | log.console.level = info
3 | log.file = false
--------------------------------------------------------------------------------
/infra/scripts/start-alertmanager.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Exit on any error
4 | # tells to exit the script if any command fails e.g false
5 | set -e
6 |
7 | # Check if required environment variables are set
8 | if [ -z "$TELEGRAM_BOT_TOKEN" ]; then
9 | echo "ERROR: TELEGRAM_BOT_TOKEN environment variable is not set"
10 | exit 1
11 | fi
12 |
13 | if [ -z "$TELEGRAM_CHAT_ID" ]; then
14 | echo "ERROR: TELEGRAM_CHAT_ID environment variable is not set"
15 | exit 1
16 | fi
17 |
18 | # Process template with envsubst if available, otherwise use sed
19 | echo "Processing Alertmanager template..."
20 | if command -v envsubst >/dev/null 2>&1; then
21 | envsubst < /etc/alertmanager/alertmanager.yml.tpl > /etc/alertmanager/alertmanager.yml
22 | else
23 | # Fallback using sed for environment variable substitution
24 | sed "s/\${TELEGRAM_BOT_TOKEN}/$TELEGRAM_BOT_TOKEN/g; s/\${TELEGRAM_CHAT_ID}/$TELEGRAM_CHAT_ID/g" \
25 | /etc/alertmanager/alertmanager.yml.tpl > /etc/alertmanager/alertmanager.yml
26 | fi
27 |
28 | # Start Alertmanager
29 | echo "Starting Alertmanager..."
30 | exec /bin/alertmanager --config.file=/etc/alertmanager/alertmanager.yml
--------------------------------------------------------------------------------
/infra/scripts/start-grafana.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Exit on any error
4 | set -e
5 |
6 | # Check if required environment variables are set
7 | if [ -z "$TELEGRAM_BOT_TOKEN" ]; then
8 | echo "ERROR: TELEGRAM_BOT_TOKEN environment variable is not set"
9 | exit 1
10 | fi
11 |
12 | if [ -z "$TELEGRAM_CHAT_ID" ]; then
13 | echo "ERROR: TELEGRAM_CHAT_ID environment variable is not set"
14 | exit 1
15 | fi
16 |
17 | # Process Grafana alerting templates
18 | echo "Processing Grafana alerting templates..."
19 |
20 | # Process contact-points template
21 | if command -v envsubst >/dev/null 2>&1; then
22 | envsubst < /etc/grafana/provisioning/alerting/contact-points.yaml.tpl > /etc/grafana/provisioning/alerting/contact-points.yaml
23 | else
24 | # Fallback using sed for environment variable substitution
25 | sed "s/\${TELEGRAM_BOT_TOKEN}/$TELEGRAM_BOT_TOKEN/g; s/\${TELEGRAM_CHAT_ID}/$TELEGRAM_CHAT_ID/g" \
26 | /etc/grafana/provisioning/alerting/contact-points.yaml.tpl > /etc/grafana/provisioning/alerting/contact-points.yaml
27 | fi
28 |
29 | echo "Grafana alerting templates processed successfully"
30 |
31 | # Start Grafana
32 | echo "Starting Grafana..."
33 | exec /run.sh
--------------------------------------------------------------------------------
/terraform/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/google" {
5 | version = "6.49.0"
6 | hashes = [
7 | "h1:2u4IviU5KYeY2sXwBWO9zk3tdVPttmRxZ1MZWsAxIsg=",
8 | "zh:1042513435b971ffbff5aeb9f7403374befe5431260944ecfbfbd1ff4d1993b9",
9 | "zh:135ec788db66381cd8be034c1f7bb18d801fa0537985edd0c1cae13c89c3b37e",
10 | "zh:2f80f2ad4b6daff7f019d4148a785af0636c5ccf76f6e277d136b19eb204ea00",
11 | "zh:37a3b686a751e46c61da529e9be2007434ba556b7fc8ecaf713c7b7c2a0a2b7f",
12 | "zh:39ef0060fc86c672f9aa817ecd389c11837063c00d08ca6c3e69369153e9424f",
13 | "zh:3b342bd8c9cdae59a88ca5e91283db958e89fdda8563f239c5547b3946466820",
14 | "zh:6a067dcd3e0321135f9867b35827bc5e5d11312b6e0a0b35d1411618b2752b98",
15 | "zh:7de572584ed0c5a0204f9ec925c75f467db9f460c268ce9ce24242d085b585ff",
16 | "zh:8be0c36fbdb7f0e2955a08283095069056d576d967c2fedbd64a95fc7586d3cd",
17 | "zh:8e4cb970df609e284ec5829ebe33933fb0f8181ded39c043c01e3c7e30dc8d4a",
18 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
19 | "zh:ff6dabaa5e005c2e7268b2bb5e9e461e5c326b698bce0ecfb1d6d078a90dd44f",
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/terraform/backend.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | backend "gcs" {
3 | # bucket and credentials configured via -backend-config during init
4 | prefix = "infra"
5 | }
6 | }
--------------------------------------------------------------------------------
/terraform/backend.tfbackend:
--------------------------------------------------------------------------------
1 | bucket = "nuspace-terraform-state"
2 | credentials = "./creds/staging.json"
3 |
4 |
--------------------------------------------------------------------------------
/terraform/envs/production.tfvars:
--------------------------------------------------------------------------------
1 | # Production environment variables
2 | credentials_file = "./creds/production.json"
3 |
4 |
5 | # Boot disk strategy
6 | use_existing_boot_disk = false
7 | use_boot_snapshot = true
8 | boot_snapshot_name = "nuspace-boot-20250901-0012"
9 | boot_disk_size_gb = 30
10 |
11 | project_id = "nuspace2025"
12 | region = "europe-central2"
13 | zone = "europe-central2-a"
14 |
15 | vm_name = "nuspace-instance"
16 | vm_machine_type = "e2-medium"
17 | vm_instance_tags = ["https-server"]
18 | static_ip_name = "nuspace-static-ip"
19 |
20 | media_bucket_name = "nuspace-media"
21 | logs_bucket_name = "nuspace-logs"
22 |
23 | # Pub/Sub
24 | topic_name = "gcs-object-created"
25 | subscription_name = "gcs-object-created-sub"
26 | subscription_suffix = "prod"
27 |
28 | # Push subscription
29 | push_endpoint = "https://nuspace.kz/api/bucket/gcs-hook"
30 | push_auth_service_account_email = "nuspace-vm-sa@nuspace2025.iam.gserviceaccount.com"
31 | push_auth_audience = "https://nuspace.kz"
32 |
33 | # Service accounts (IDs)
34 | vm_account_id = "nuspace-vm-sa"
35 | ansible_account_id = "nuspace-ansible-sa"
36 | signing_account_id = "nuspace-signing-sa"
37 |
38 | media_migration_region = "europe-central2"
39 |
40 | # WIF: GitHub repo allowed to impersonate (format: owner/repo)
41 | github_repository = "ulanpy/nuspace"
--------------------------------------------------------------------------------
/terraform/envs/staging.tfvars:
--------------------------------------------------------------------------------
1 | # Staging environment variables
2 | credentials_file = "./creds/staging.json"
3 |
4 | # Boot disk strategy
5 | use_existing_boot_disk = false
6 | use_boot_snapshot = false
7 | boot_snapshot_name = null
8 | boot_disk_size_gb = 30
9 | boot_disk_type = "pd-standard"
10 |
11 |
12 | project_id = "nuspace-staging"
13 | region = "europe-central2"
14 | zone = "europe-central2-a"
15 |
16 | vm_name = "nuspace-instance"
17 | vm_machine_type = "e2-medium"
18 | vm_instance_tags = ["https-server"]
19 | static_ip_name = "nuspace-static-ip"
20 |
21 | media_bucket_name = "nuspace-media-staging"
22 | logs_bucket_name = "nuspace-logs-staging"
23 |
24 | # Pub/Sub
25 | topic_name = "gcs-object-created"
26 | subscription_name = "gcs-object-created-sub"
27 | subscription_suffix = "staging"
28 |
29 | # Push subscription
30 | push_endpoint = "https://stage.nuspace.kz/api/bucket/gcs-hook"
31 | push_auth_service_account_email = "nuspace-vm-sa@nuspace-staging.iam.gserviceaccount.com"
32 | push_auth_audience = "https://stage.nuspace.kz"
33 |
34 | # Service accounts (IDs)
35 | vm_account_id = "nuspace-vm-sa"
36 | ansible_account_id = "nuspace-ansible-sa"
37 | signing_account_id = "nuspace-signing-sa"
38 |
39 |
40 | media_migration_region = "europe-central2"
41 |
42 | # WIF: GitHub repo allowed to impersonate (format: owner/repo)
43 | github_repository = "ulanpy/nuspace"
--------------------------------------------------------------------------------
/terraform/providers.tf:
--------------------------------------------------------------------------------
1 | # providers.tf
2 |
3 | provider "google" {
4 | project = var.project_id
5 | credentials = file(var.credentials_file)
6 | region = var.region
7 | zone = var.zone
8 | }
9 |
--------------------------------------------------------------------------------
/terraform/tfscheme.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/9f3a999ecdf70c1ab2a5285c4d477bc7df0c3530/terraform/tfscheme.png
--------------------------------------------------------------------------------