initian commit

This commit is contained in:
2025-08-16 13:08:52 +02:00
parent 7118c8a7f0
commit bf061bff08
22 changed files with 3155 additions and 321 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.next
.git
Dockerfile
.dockerignore
*.log

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# ---------- deps ----------
FROM node:20-alpine AS deps
# helps certain native packages (e.g., sharp) on Alpine
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
# install with your lockfile manager
RUN if [ -f pnpm-lock.yaml ]; then npm i -g pnpm@8 && pnpm i --frozen-lockfile; \
elif [ -f yarn.lock ]; then yarn install --frozen-lockfile; \
else npm ci; fi
# ---------- builder ----------
FROM node:20-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ---------- runner ----------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# non-root user
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -s /bin/sh -D nextjs
# copy the minimal standalone output
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
EXPOSE 3000
CMD ["node", "server.js"]

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
services:
next:
build:
context: .
dockerfile: Dockerfile
image: my-next:latest
container_name: nextjs
restart: unless-stopped
environment:
- NODE_ENV=production
- TZ=Europe/Ljubljana
# add any Next.js runtime env vars you need, e.g.:
# - NEXTAUTH_URL=https://example.com
# - NEXT_PUBLIC_API_BASE=https://api.example.com
networks:
- proxy
labels:
- "traefik.enable=true"
# tell Traefik which network to use to reach the container
- "traefik.docker.network=proxy"
# Router (replace example.com)
- "traefik.http.routers.next.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.next.entrypoints=websecure"
- "traefik.http.routers.next.tls=true"
# use your existing certresolver name from your Traefik config
- "traefik.http.routers.next.tls.certresolver=letsencrypt"
# Service (container listens on 3000)
- "traefik.http.services.next.loadbalancer.server.port=3000"
networks:
proxy:
external: true

View File

@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
output: 'standalone',
telemetry: false,
}; };
export default nextConfig; export default nextConfig;

1944
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,34 @@
{ {
"name": "petadvisor", "name": "petadviser",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@chakra-ui/react": "^3.24.0",
"@emotion/react": "^11.14.0",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.53.0",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.12",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"next": "15.4.5",
"next-themes": "^0.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"next": "15.4.5" "react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-leaflet-markercluster": "^5.0.0-rc.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19" "@types/react-dom": "^19",
"typescript": "^5"
} }
} }

View File

@@ -1,42 +1,46 @@
:root { @import "leaflet/dist/leaflet.css";
--background: #ffffff; @import "leaflet.markercluster/dist/MarkerCluster.css";
--foreground: #171717; @import "leaflet.markercluster/dist/MarkerCluster.Default.css";
}
@media (prefers-color-scheme: dark) { /*:root {*/
:root { /* --background: #ffffff;*/
--background: #0a0a0a; /* --foreground: #171717;*/
--foreground: #ededed; /*}*/
}
}
html, /*@media (prefers-color-scheme: dark) {*/
body { /* :root {*/
max-width: 100vw; /* --background: #0a0a0a;*/
overflow-x: hidden; /* --foreground: #ededed;*/
} /* }*/
/*}*/
body { /*html,*/
color: var(--foreground); /*body {*/
background: var(--background); /* max-width: 100vw;*/
font-family: Arial, Helvetica, sans-serif; /* overflow-x: hidden;*/
-webkit-font-smoothing: antialiased; /*}*/
-moz-osx-font-smoothing: grayscale;
}
* { /*body {*/
box-sizing: border-box; /* color: var(--foreground);*/
padding: 0; /* background: var(--background);*/
margin: 0; /* font-family: Arial, Helvetica, sans-serif;*/
} /* -webkit-font-smoothing: antialiased;*/
/* -moz-osx-font-smoothing: grayscale;*/
/*}*/
a { /** {*/
color: inherit; /* box-sizing: border-box;*/
text-decoration: none; /* padding: 0;*/
} /* margin: 0;*/
/*}*/
@media (prefers-color-scheme: dark) { /*a {*/
html { /* color: inherit;*/
color-scheme: dark; /* text-decoration: none;*/
} /*}*/
}
/*@media (prefers-color-scheme: dark) {*/
/* html {*/
/* color-scheme: dark;*/
/* }*/
/*}*/

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type {Metadata} from "next";
import { Geist, Geist_Mono } from "next/font/google"; import {Geist, Geist_Mono} from "next/font/google";
import "./globals.css"; import "./globals.css";
import {Provider} from "@/components/ui/provider"
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -13,19 +14,19 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Pet Adviser",
description: "Generated by create next app", description: "Generated by create next app",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html suppressHydrationWarning lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}> <body className={`${geistSans.variable} ${geistMono.variable}`}>
{children} <Provider>{children}</Provider>
</body> </body>
</html> </html>
); );

View File

@@ -1,167 +1,3 @@
.page {
--gray-rgb: 0, 0, 0;
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
display: grid;
grid-template-rows: 20px 1fr 20px;
align-items: center;
justify-items: center;
min-height: 100svh;
padding: 80px;
gap: 64px;
font-family: var(--font-geist-sans);
}
@media (prefers-color-scheme: dark) {
.page {
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
}
}
.main {
display: flex;
flex-direction: column;
gap: 32px;
grid-row-start: 2;
}
.main ol {
font-family: var(--font-geist-mono);
padding-left: 0;
margin: 0;
font-size: 14px;
line-height: 24px;
letter-spacing: -0.01em;
list-style-position: inside;
}
.main li:not(:last-of-type) {
margin-bottom: 8px;
}
.main code {
font-family: inherit;
background: var(--gray-alpha-100);
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.ctas {
display: flex;
gap: 16px;
}
.ctas a {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
border: 1px solid transparent;
transition:
background 0.2s,
color 0.2s,
border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
}
a.primary {
background: var(--foreground);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--gray-alpha-200);
min-width: 158px;
}
.footer { .footer {
grid-row-start: 3; background-color: #f6f6f6;
display: flex;
gap: 24px;
}
.footer a {
display: flex;
align-items: center;
gap: 8px;
}
.footer img {
flex-shrink: 0;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
.footer a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
}
@media (max-width: 600px) {
.page {
padding: 32px;
padding-bottom: 80px;
}
.main {
align-items: center;
}
.main ol {
text-align: center;
}
.ctas {
flex-direction: column;
}
.ctas a {
font-size: 14px;
height: 40px;
padding: 0 16px;
}
a.secondary {
min-width: auto;
}
.footer {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
} }

View File

@@ -1,94 +1,39 @@
import Image from "next/image"; import Image from "next/image";
import styles from "./page.module.css"; import styles from "./page.module.css";
import { Container, Heading } from "@chakra-ui/react"
import Header from "@/components/Header";
import HeroBanner from "@/components/HeroBanner";
import CompaniesExplorer from "@/components/Map/CompaniesExplorer.tsx";
import type { Company } from "@/components/Map/CompanyMap.tsx";
import Articles from "@/components/Articles";
import Calendar from "@/components/Calendar";
import Sponsor from "@/components/Sponsor";
import Footer from "@/components/Footer";
const seed: Company[] = [
{ id: "1", name: "ByteForge", industry: "Tech", lat: 46.0569, lng: 14.5058, address: "Ljubljana" },
{ id: "2", name: "GreenPlate", industry: "Food", lat: 46.2389, lng: 15.2675, address: "Celje" },
{ id: "3", name: "Medicus+", industry: "Healthcare", lat: 46.5547, lng: 15.6459, address: "Maribor" },
{ id: "4", name: "ShopNook", industry: "Retail", lat: 45.5481, lng: 13.7302, address: "Koper" },
{ id: "5", name: "Steelworks d.o.o.", industry: "Manufacturing", lat: 46.2396, lng: 14.3556, address: "Kranj" },
{ id: "6", name: "CloudLynx", industry: "Tech", lat: 46.3607, lng: 14.0888, address: "Škofja Loka" },
];
export default function Home() { export default function Home() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<header>
<Header/>
</header>
<main className={styles.main}> <main className={styles.main}>
<Image <HeroBanner/>
className={styles.logo} <CompaniesExplorer companies={seed} />
src="/next.svg" <Articles />
alt="Next.js logo" <Sponsor />
width={180} <Calendar initialDate={new Date()} />
height={38}
priority
/>
<ol>
<li>
Get started by editing <code>src/app/page.tsx</code>.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
className={styles.secondary}
>
Read our docs
</a>
</div>
</main> </main>
<footer className={styles.footer}> <footer className={styles.footer}>
<a <Footer />
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer> </footer>
</div> </div>
); );

View File

@@ -0,0 +1,35 @@
import { createClient } from '@/utils/supabase/server';
import { Container, Button, Card, Heading, Image, Text, SimpleGrid, GridItem } from "@chakra-ui/react"
export default async function Articles() {
const supabase = await createClient();
const { data: articles } = await supabase.from("articles").select().order("published_at", {ascending: false}).limit(8);
return (
<Container>
<Heading size="4xl" as="h2">Zadnje novice iz sveta malih živali</Heading>
{articles.length === 0 ? <Text>Ni vsebin</Text> : <SimpleGrid columns={4} gap="40px">
{articles.map((article) => (
<Card.Root key={article.id} maxW="sm" overflow="hidden">
<Image
src={article.image_url}
alt="Green double couch with wooden legs"
/>
<Card.Body gap="2">
<Card.Title>{article.title}</Card.Title>
<Card.Description>
{article.name}
</Card.Description>
<small className="text-gray-500">
{new Date(article.published_at).toLocaleString("sl-SI", {weekday: "long", year: "numeric", month: "long", day: "numeric"})}
</small>
</Card.Body>
<Card.Footer gap="2">
<Button variant="outline">Ogled objave</Button>
</Card.Footer>
</Card.Root>
))}
</SimpleGrid>}
</Container>
)
}

381
src/components/Calendar.js Normal file
View File

@@ -0,0 +1,381 @@
'use client';
import React, { useMemo, useState } from "react";
import {
addMonths,
subMonths,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addDays,
isSameMonth,
isSameDay,
format,
parseISO,
isAfter,
} from "date-fns";
import { FaChevronLeft, FaChevronRight, FaCalendarPlus, FaClock, FaMapMarkerAlt, FaRegCalendarAlt } from "react-icons/fa";
import { motion, AnimatePresence } from "framer-motion";
import {
Container,
Box,
Flex,
Grid,
GridItem,
Heading,
Text,
Button,
IconButton,
Badge,
VStack,
HStack,
Input,
} from "@chakra-ui/react";
// --- Demo Data ---
const demoEvents = [
{ id: "1", title: "Dogodek 1", start: new Date().toISOString(), color: "blue.500" },
{ id: "2", title: "Dogodek 2", start: addDays(new Date(), 2).toISOString(), color: "pink.500" },
{ id: "3", title: "Dogodek 3", start: addDays(new Date(), 6).toISOString(), color: "green.500" },
{ id: "4", title: "Dogodek 4", start: addDays(new Date(), 12).toISOString(), color: "orange.500" },
];
// --- Helpers ---
const getMonthMatrix = (current) => {
const monthStart = startOfMonth(current);
const monthEnd = endOfMonth(current);
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
const rows = [];
let day = startDate;
let row = [];
while (day <= endDate) {
for (let i = 0; i < 7; i++) {
row.push(day);
day = addDays(day, 1);
}
rows.push(row);
row = [];
}
return rows;
};
const DayBadge = ({ count }) =>
count > 0 ? (
<Badge ml={1} fontSize="0.6rem" colorScheme="purple" variant="subtle" borderRadius="full" px={2} py={0.5}>
{count}
</Badge>
) : null;
const EventPill = ({ ev }) => (
<HStack spacing={2} px={2} py={1} borderRadius="xl" bg={ev.color ?? "gray.500"} color="white" _hover={{ opacity: 0.9 }}>
<Text fontSize="xs" noOfLines={1} fontWeight="medium">
{ev.title}
</Text>
</HStack>
);
// --- Main Component (Plain JS, Chakra v1/v2 compatible) ---
export default function Calendar({ initialDate, initialEvents = demoEvents }) {
// Static tokens (works without Chakra hooks)
const mutedColor = "gray.600";
const tileBg = "white";
const tileBgMuted = "gray.50";
const selectedRing = "purple.400";
const [currentDate, setCurrentDate] = useState(
initialDate ? (typeof initialDate === "string" ? parseISO(initialDate) : initialDate) : new Date()
);
const [selectedDate, setSelectedDate] = useState(new Date());
const [events, setEvents] = useState(initialEvents);
const monthMatrix = useMemo(() => getMonthMatrix(currentDate), [currentDate]);
const eventsByDay = useMemo(() => {
const map = new Map();
events.forEach((ev) => {
const d = format(parseISO(ev.start), "yyyy-MM-dd");
if (!map.has(d)) map.set(d, []);
map.get(d).push(ev);
});
return map;
}, [events]);
const todayKey = format(new Date(), "yyyy-MM-dd");
const upcomingEvents = useMemo(() => {
const now = new Date();
return [...events]
.filter((e) => isAfter(parseISO(e.start), addDays(now, -1)))
.sort((a, b) => parseISO(a.start).getTime() - parseISO(b.start).getTime());
}, [events]);
// Quick add form state
const [newTitle, setNewTitle] = useState("");
const [newDate, setNewDate] = useState(format(new Date(), "yyyy-MM-dd"));
const [newTime, setNewTime] = useState("09:00");
const [newLocation, setNewLocation] = useState("");
const addEvent = () => {
if (!newTitle.trim()) return;
const dt = new Date(`${newDate}T${newTime}:00`);
setEvents((prev) => [
...prev,
{
id: typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : String(Date.now()),
title: newTitle.trim(),
start: dt.toISOString(),
location: newLocation.trim() || undefined,
color: "purple.500",
},
]);
setNewTitle("");
setNewLocation("");
};
return (
<Container>
<Box maxW="6xl" mx="auto" p={{base: 4, md: 8}}>
<Flex mb={6} direction={{base: "column", sm: "row"}} gap={3} align={{sm: "center"}}
justify="space-between">
<HStack spacing={2}>
<Heading size="4xl" as="h2">
Prihajajoči dogodki
</Heading>
<FaRegCalendarAlt size={20}/>
</HStack>
<HStack spacing={2}>
<Button variant="outline" size="sm" onClick={() => setCurrentDate(new Date())}>
Danes
</Button>
<HStack spacing={1} borderWidth="1px" borderRadius="2xl" p={1}>
<IconButton aria-label="Prejšnji mesec" variant="ghost" size="sm"
onClick={() => setCurrentDate((d) => subMonths(d, 1))}>
<FaChevronLeft/>
</IconButton>
<Text px={3} fontSize="sm" fontWeight="medium" minW="140px" textAlign="center">
{format(currentDate, "MMMM yyyy")}
</Text>
<IconButton aria-label="Naslednji mesec" variant="ghost" size="sm"
onClick={() => setCurrentDate((d) => addMonths(d, 1))}>
<FaChevronRight/>
</IconButton>
</HStack>
</HStack>
</Flex>
<Grid templateColumns={{base: "1fr", lg: "2fr 1fr"}} gap={6}>
{/* Calendar Grid */}
<Box borderWidth="1px" borderRadius="2xl" overflow="hidden">
<Box pb={0} borderBottomWidth="1px" px={4}>
<Grid templateColumns="repeat(7, 1fr)" color={mutedColor} fontSize="xs" fontWeight="medium">
{["Pon", "Tor", "Sre", "Čet", "Pet", "Sob", "Ned"].map((d) => (
<GridItem key={d} px={2} py={3} textAlign="center">
{d}
</GridItem>
))}
</Grid>
</Box>
<Box pt={2} px={4}>
<Grid templateColumns="repeat(7, 1fr)" gap={1}>
{monthMatrix.flat().map((day, idx) => {
const inMonth = isSameMonth(day, currentDate);
const key = format(day, "yyyy-MM-dd");
const isToday = key === todayKey;
const isSelected = selectedDate && isSameDay(day, selectedDate);
const dayEvents = eventsByDay.get(key) || [];
return (
<Box
as="button"
key={idx}
onClick={() => setSelectedDate(day)}
h="96px"
p={2}
borderRadius="2xl"
bg={inMonth ? tileBg : tileBgMuted}
color={inMonth ? undefined : mutedColor}
borderWidth={isSelected ? "2px" : "0px"}
borderColor={isSelected ? selectedRing : "transparent"}
>
<Flex align="center">
<HStack>
<Flex
align="center"
justify="center"
h={6}
w={6}
borderRadius="full"
fontSize="xs"
fontWeight="semibold"
bg={isToday ? "purple.500" : "transparent"}
color={isToday ? "white" : undefined}
>
{format(day, "d")}
</Flex>
<DayBadge count={dayEvents.length}/>
</HStack>
</Flex>
<VStack mt={2} align="stretch" spacing={1} maxH="56px" overflow="hidden">
<AnimatePresence initial={false}>
{dayEvents.slice(0, 3).map((ev) => (
<motion.div key={ev.id} initial={{opacity: 0, y: -6}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: 6}}>
<EventPill ev={ev}/>
</motion.div>
))}
</AnimatePresence>
{dayEvents.length > 3 && (
<Text fontSize="xs" color={mutedColor} noOfLines={1}>
+{dayEvents.length - 3} more
</Text>
)}
</VStack>
</Box>
);
})}
</Grid>
{/* Selected Day Drawer */}
<AnimatePresence>
{selectedDate && (
<motion.div initial={{opacity: 0, y: 10}} animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: 10}}>
<Box mt={4} borderWidth="1px" borderRadius="2xl" p={4}>
<Text fontSize="sm" color={mutedColor}>
Na izbran dan
</Text>
<Text fontSize="md" fontWeight="medium">
{format(selectedDate, "EEEE, MMM d")}
</Text>
<VStack align="stretch" spacing={2} mt={2}>
{(eventsByDay.get(format(selectedDate, "yyyy-MM-dd")) || []).length === 0 ? (
<Text fontSize="sm" color={mutedColor}>
No events for this day.
</Text>
) : (
(eventsByDay.get(format(selectedDate, "yyyy-MM-dd")) || []).map((ev) => (
<HStack key={ev.id} align="start" spacing={3} borderWidth="1px"
borderRadius="xl" p={3}>
<Box mt={0.5} h={2.5} w={2.5} borderRadius="full"
bg={ev.color ?? "gray.400"}/>
<Box flex="1">
<Text fontWeight="medium" noOfLines={2}>
{ev.title}
</Text>
<HStack mt={1} spacing={3} wrap="wrap"
color={mutedColor} fontSize="xs">
<HStack spacing={1}>
<FaClock/>
<Text>{format(parseISO(ev.start), "EEE, MMM d HH:mm")}</Text>
</HStack>
{ev.location && (
<HStack spacing={1}>
<FaMapMarkerAlt/>
<Text>{ev.location}</Text>
</HStack>
)}
</HStack>
</Box>
</HStack>
))
)}
</VStack>
</Box>
</motion.div>
)}
</AnimatePresence>
</Box>
</Box>
{/* Sidebar */}
<VStack spacing={6} align="stretch">
{/* Quick add
<Box borderWidth="1px" borderRadius="2xl">
<Box pb={0} borderBottomWidth="1px" px={4} py={3}>
<Heading as="h3" size="sm">
Quick add event
</Heading>
</Box>
<Box pt={4} px={4} pb={4}>
<VStack spacing={3} align="stretch">
<Box>
<Text as="label" htmlFor="title" fontSize="sm" fontWeight="medium" mb={1} display="block">Title</Text>
<Input id="title" value={newTitle} onChange={(e) => setNewTitle(e.target.value)} />
</Box>
<Grid templateColumns="repeat(2, 1fr)" gap={3}>
<Box>
<Text as="label" htmlFor="date" fontSize="sm" fontWeight="medium" mb={1} display="block">Date</Text>
<Input id="date" type="date" value={newDate} onChange={(e) => setNewDate(e.target.value)} />
</Box>
<Box>
<Text as="label" htmlFor="time" fontSize="sm" fontWeight="medium" mb={1} display="block">Time</Text>
<Input id="time" type="time" value={newTime} onChange={(e) => setNewTime(e.target.value)} />
</Box>
</Grid>
<Box>
<Text as="label" htmlFor="location" fontSize="sm" fontWeight="medium" mb={1} display="block">Location</Text>
<Input id="location" value={newLocation} onChange={(e) => setNewLocation(e.target.value)} />
</Box>
<Button onClick={addEvent} leftIcon={<FaCalendarPlus />} colorScheme="purple" alignSelf="flex-start">
Add
</Button>
</VStack>
</Box>
</Box>*/}
{/* Upcoming */}
<Box h="520px" borderWidth="1px" borderRadius="2xl" overflow="hidden">
<Box pb={0} borderBottomWidth="1px" px={4} py={3}>
<Heading as="h3" size="sm">
Prihajajoči dogodki
</Heading>
</Box>
<Box pt={3} px={4} h="full" overflowY="auto" pr={3} pb={4}>
<VStack spacing={3} align="stretch">
{upcomingEvents.length === 0 ? (
<Text fontSize="sm" color={mutedColor}>
Trenutno prihodnjih dogodkov.
</Text>
) : (
upcomingEvents.map((ev) => (
<HStack key={ev.id} align="start" spacing={3} borderWidth="1px"
borderRadius="xl" p={3}>
<Box mt={0.5} h={2.5} w={2.5} borderRadius="full"
bg={ev.color ?? "gray.400"}/>
<Box flex="1" minW={0}>
<Text fontWeight="medium" noOfLines={1}>
{ev.title}
</Text>
<HStack mt={1} spacing={3} wrap="wrap" color={mutedColor}
fontSize="xs">
<HStack spacing={1}>
<FaClock/>
<Text>{format(parseISO(ev.start), "EEE, MMM d HH:mm")}</Text>
</HStack>
{ev.location && (
<HStack spacing={1}>
<FaMapMarkerAlt/>
<Text>{ev.location}</Text>
</HStack>
)}
</HStack>
</Box>
</HStack>
))
)}
</VStack>
</Box>
</Box>
</VStack>
</Grid>
</Box>
</Container>
);
}

43
src/components/Footer.js Normal file
View File

@@ -0,0 +1,43 @@
import {Container, Grid, List, Heading, Image, IconButton, Stack} from "@chakra-ui/react";
import { LuPhone, LuMail } from "react-icons/lu"
export default function Footer() {
return (
<Container>
<Grid templateColumns="repeat(3, 1fr)" gap="6">
<div>
<Heading as="h3">Uporabne povezave</Heading>
<List.Root>
<List.Item>Povezava 1</List.Item>
<List.Item>Povezava 2</List.Item>
<List.Item>Povezava 3</List.Item>
<List.Item>Povezava 4</List.Item>
</List.Root>
</div>
<div>
<Heading as="h3">PetAdviser</Heading>
<List.Root>
<List.Item>O nas 1</List.Item>
<List.Item>O nas 2</List.Item>
<List.Item>O nas 3</List.Item>
<List.Item>O nas 4</List.Item>
</List.Root>
</div>
<div>
<Heading as="h3">Vizitka</Heading>
<Image src="/logo.svg" alt="Pet Adviser Logo" h="40px" />
<Stack direction={{ base: "column", md: "column" }} gap="4">
<IconButton variant="outline" aria-label="E-posta" rounded="full">
<LuMail />
info@petadviser.si
</IconButton>
<IconButton variant="outline" aria-label="Telefon" rounded="full">
<LuPhone />
000 000 000
</IconButton>
</Stack>
</div>
</Grid>
</Container>
);
}

77
src/components/Header.js Normal file
View File

@@ -0,0 +1,77 @@
"use client";
import NextLink from "next/link";
import {
Flex,
Box,
HStack,
Link,
Image,
Spacer,
IconButton,
ClientOnly,
Skeleton
} from "@chakra-ui/react";
import { FaCircleUser } from "react-icons/fa6";
import { useColorMode } from "@/components/ui/color-mode"
import { LuMoon, LuSun } from "react-icons/lu"
import styles from "./Header.module.css";
import {MENU_ITEMS} from "../lib/constants";
export default function Header() {
const { toggleColorMode, colorMode } = useColorMode()
return (
<Box
as="header"
bg="white"
boxShadow="sm"
position="sticky"
top={0}
zIndex={10}
>
{/* First row: Logo + User menu */}
<Flex
px={8}
py={2}
align="center"
justify="space-between"
borderBottom="1px solid"
borderColor="gray.100"
>
{/* Logo */}
<Link as={NextLink} href="/" _hover={{ textDecoration: "none" }}>
<Image src="/logo.svg" alt="Logo" h="40px" />
</Link>
<Spacer />
{/* User Menu */}
<IconButton aria-label="Call support" rounded="full">
<FaCircleUser />
</IconButton>
<ClientOnly fallback={<Skeleton boxSize="8" />}>
<IconButton onClick={toggleColorMode} variant="outline" size="sm">
{colorMode === "light" ? <LuSun /> : <LuMoon />}
</IconButton>
</ClientOnly>
</Flex>
{/* Second row: Navigation menu */}
<Flex px={8} py={3} as="nav">
<HStack spacing={6}>
{MENU_ITEMS.map((item) => (
<Link
key={item.href}
as={NextLink}
href={item.href}
fontWeight="medium"
_hover={{ color: "teal.500" }}
fontSize="md"
>
{item.label}
</Link>
))}
</HStack>
</Flex>
</Box>
);
}

View File

View File

@@ -0,0 +1,62 @@
import {Box, Flex, Button, HStack, Wrap, Link, Text, Heading} from "@chakra-ui/react";
import NextLink from "next/link";
import {RiArrowRightLine, RiMailLine} from "react-icons/ri"
import {MENU_ITEMS} from "../lib/constants";
export default function Banner() {
return (
<Box position="relative" width="100%" height="400px" overflow="hidden">
{/* Background Video */}
<Box
as="video"
autoPlay
muted
loop
playsInline
objectFit="cover"
width="100%"
height="100%"
position="absolute"
top={0}
left={0}
zIndex={0}
>
<source src="/hero-video.mp4" type="video/mp4"/>
</Box>
{/* Overlay */}
<Flex
direction="column"
justify="center"
align="center"
position="relative"
zIndex={1}
bg="rgba(0, 0, 0, 0.4)"
color="white"
height="100%"
textAlign="center"
px={4}
>
{/* Navigation menu on top of banner */}
<Heading as="h1">Vse za vase male in malo vecje zivali</Heading>
<Wrap gap={10} justify="center">
{MENU_ITEMS.map((item) => (
<Button
key={item.href}
colorPalette="white"
variant="solid"
asChild
>
<a href={item.href}><RiMailLine/> {item.label}</a>
</Button>
))}
</Wrap>
{/* Example headline in the center */}
{/*<Text fontSize="3xl" fontWeight="bold">
Welcome to Our Website
</Text>*/}
</Flex>
</Box>
);
}

0
src/components/Map.js Normal file
View File

View File

@@ -0,0 +1,147 @@
"use client";
import { Box, Flex, Heading, HStack, Input, Stack, Text, Badge } from "@chakra-ui/react";
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import type { Company } from "./CompanyMap";
// dynamic import to avoid SSR issues
const CompanyMap = dynamic(() => import("./CompanyMap"), { ssr: false });
export type CompaniesExplorerProps = {
companies: Company[];
industries?: Array<Company["industry"]>;
initialIndustry?: Company["industry"] | "All";
showAutoFit?: boolean;
};
const defaultIndustries: Array<Company["industry"]> = [
"Tech",
"Retail",
"Manufacturing",
"Food",
"Healthcare",
];
export default function CompaniesExplorer({
companies,
industries = defaultIndustries,
initialIndustry = "All",
showAutoFit = true,
}: CompaniesExplorerProps) {
const [query, setQuery] = useState("");
const [industry, setIndustry] = useState<string>(initialIndustry);
const [selectedId, setSelectedId] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return companies.filter((c) => {
const matchesQ =
!q ||
c.name.toLowerCase().includes(q) ||
(c.address?.toLowerCase().includes(q) ?? false);
const matchesInd = industry === "All" || c.industry === industry;
return matchesQ && matchesInd;
});
}, [companies, query, industry]);
const selected = useMemo(
() => filtered.find((c) => c.id === selectedId),
[filtered, selectedId]
);
return (
<Flex h="100dvh" w="100%" p={4} gap={4} wrap={{ base: "wrap", lg: "nowrap" }}>
{/* Left panel */}
<Box
w={{ base: "100%", lg: "35%" }}
h={{ base: "48dvh", lg: "100%" }}
borderRadius="2xl"
p={4}
bg="white"
boxShadow="sm"
overflow="hidden"
>
<HStack justify="space-between" mb={3}>
<Heading size="md">Companies</Heading>
</HStack>
<HStack mb={3} spacing={2}>
<Input
placeholder="Search by name or city..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Box as="select"
value={industry}
onChange={(e) => setIndustry(e.target.value)}
maxW="44"
borderWidth="1px"
borderRadius="md"
px={3}
py={2}
>
<option value="All">All</option>
{industries.map((i) => (
<option key={i} value={i}>{i}</option>
))}
</Box>
</HStack>
<Box overflowY="auto" h={{ base: "calc(48dvh - 120px)", lg: "calc(100% - 120px)" }} pr={2}>
<Stack spacing={2}>
{filtered.map((c) => {
const active = c.id === selectedId;
return (
<Box
key={c.id}
p={3}
borderRadius="xl"
borderWidth={active ? "2px" : "1px"}
borderColor={active ? "blue.400" : "gray.200"}
bg={active ? "blue.50" : "transparent"}
cursor="pointer"
onClick={() => setSelectedId(c.id)}
_hover={{ borderColor: "blue.300" }}
>
<HStack justify="space-between" align="start">
<Stack spacing={0}>
<Text fontWeight="semibold">{c.name}</Text>
{c.address && (
<Text fontSize="sm" color="gray.500">
{c.address}
</Text>
)}
</Stack>
<Badge>{c.industry}</Badge>
</HStack>
</Box>
);
})}
{filtered.length === 0 && (
<Text color="gray.500" textAlign="center" mt={6}>
No companies match your filters.
</Text>
)}
</Stack>
</Box>
</Box>
{/* Right panel */}
<Box
flex="1"
h={{ base: "48dvh", lg: "100%" }}
borderRadius="2xl"
overflow="hidden"
boxShadow="sm"
>
<CompanyMap
companies={filtered}
selectedId={selected?.id ?? null}
onSelect={(id) => setSelectedId(id)}
autoFit={showAutoFit}
/>
</Box>
</Flex>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { Box, Badge, Flex, Text } from "@chakra-ui/react";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
import L, { DivIcon, LatLngBoundsLiteral } from "leaflet";
import "leaflet/dist/leaflet.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
import MarkerClusterGroup from "react-leaflet-markercluster";
import { useEffect, useMemo } from "react";
export type Company = {
id: string;
name: string;
industry: "Tech" | "Retail" | "Manufacturing" | "Food" | "Healthcare";
lat: number;
lng: number;
address?: string;
};
type Props = {
companies: Company[];
selectedId?: string | null;
onSelect?: (id: string) => void;
autoFit?: boolean;
};
const industryEmoji: Record<Company["industry"], string> = {
Tech: "💻",
Retail: "🛒",
Manufacturing: "🏭",
Food: "🍽️",
Healthcare: "🏥",
};
const industryColor: Record<Company["industry"], string> = {
Tech: "#3b82f6",
Retail: "#f59e0b",
Manufacturing: "#6b7280",
Food: "#10b981",
Healthcare: "#ef4444",
};
function makeIcon(industry: Company["industry"], highlighted: boolean): DivIcon {
const size = highlighted ? 40 : 32;
const color = industryColor[industry];
const emoji = industryEmoji[industry];
const border = highlighted ? `3px solid ${color}` : `2px solid ${color}`;
return L.divIcon({
className: "company-div-icon",
html: `
<div style="
width:${size}px;height:${size}px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
background:#fff;border:${border};
box-shadow:0 2px 8px rgba(0,0,0,.15);
font-size:${Math.round(size * 0.55)}px;
">${emoji}</div>
`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -size / 2],
});
}
function FlyTo({ lat, lng }: { lat: number; lng: number }) {
const map = useMap();
useEffect(() => {
map.flyTo([lat, lng], Math.max(map.getZoom(), 13), { duration: 0.7 });
}, [lat, lng, map]);
return null;
}
function FitBoundsOnData({ points }: { points: { lat: number; lng: number }[] }) {
const map = useMap();
useEffect(() => {
if (!points?.length) return;
let bounds: LatLngBoundsLiteral = [
[points[0].lat, points[0].lng],
[points[0].lat, points[0].lng],
];
for (const p of points) {
bounds = L.latLngBounds(bounds).extend([p.lat, p.lng]) as any;
}
map.fitBounds(bounds, { padding: [30, 30] });
}, [points, map]);
return null;
}
export default function CompanyMap({ companies, selectedId, onSelect, autoFit = true }: Props) {
const selected = useMemo(
() => companies.find((c) => c.id === selectedId),
[companies, selectedId]
);
const center = useMemo<[number, number]>(() => {
if (!companies.length) return [46.0569, 14.5058]; // Ljubljana fallback
const lat = companies.reduce((a, c) => a + c.lat, 0) / companies.length;
const lng = companies.reduce((a, c) => a + c.lng, 0) / companies.length;
return [lat, lng];
}, [companies]);
return (
<Box w="100%" h="100%" borderRadius="2xl" overflow="hidden" bg="white">
<MapContainer
center={center}
zoom={6}
style={{ width: "100%", height: "100%" }}
preferCanvas
>
<TileLayer
attribution="&copy; OpenStreetMap"
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MarkerClusterGroup chunkedLoading>
{companies.map((c) => (
<Marker
key={c.id}
position={[c.lat, c.lng]}
icon={makeIcon(c.industry, c.id === selectedId)}
eventHandlers={{
click: () => onSelect?.(c.id),
mouseover: (e) => e.target.openPopup(),
}}
>
<Popup>
<Flex direction="column" gap={1}>
<Text fontWeight="bold">{c.name}</Text>
<Badge
w="fit-content"
colorScheme="gray"
style={{ background: industryColor[c.industry], color: "#fff" }}
>
{c.industry}
</Badge>
{c.address && <Text fontSize="sm">{c.address}</Text>}
</Flex>
</Popup>
</Marker>
))}
</MarkerClusterGroup>
{autoFit && companies.length > 0 && !selected && (
<FitBoundsOnData points={companies.map(({ lat, lng }) => ({ lat, lng }))} />
)}
{selected && <FlyTo lat={selected.lat} lng={selected.lng} />}
</MapContainer>
</Box>
);
}

59
src/components/Sponsor.js Normal file
View File

@@ -0,0 +1,59 @@
import { Container, Stack, Badge, Box, Button, Card, HStack, Image, Heading } from "@chakra-ui/react"
export default function Sponsor() {
return (
<Container>
<Heading size="4xl" as="h2" >Sponzorji meseca</Heading>
<Stack direction="row" wrap="wrap" gap={4}>
<Card.Root flexDirection="row" overflow="hidden" maxW="xl">
<Image
objectFit="contain"
maxW="200px"
src="https://kanimedico.com/wp-content/uploads/2024/12/logo.svg"
alt="Kani Medico"
/>
<Box>
<Card.Body>
<Card.Title mb="2">The perfect latte</Card.Title>
<Card.Description>
Caffè latte is a coffee beverage of Italian origin made with espresso
and steamed milk.
</Card.Description>
<HStack mt="4">
<Badge>Hot</Badge>
<Badge>Caffeine</Badge>
</HStack>
</Card.Body>
<Card.Footer>
<Button>Buy Latte</Button>
</Card.Footer>
</Box>
</Card.Root>
<Card.Root flexDirection="row" overflow="hidden" maxW="xl">
<Image
objectFit="contain"
maxW="200px"
src="https://www.hepifit.si/wp-content/uploads/2020/02/HepiFit_logo-500x180-1-e1589702333940.png"
alt="Hepi Fit"
/>
<Box>
<Card.Body>
<Card.Title mb="2">The perfect latte</Card.Title>
<Card.Description>
Caffè latte is a coffee beverage of Italian origin made with espresso
and steamed milk.
</Card.Description>
<HStack mt="4">
<Badge>Hot</Badge>
<Badge>Caffeine</Badge>
</HStack>
</Card.Body>
<Card.Footer>
<Button>Buy Latte</Button>
</Card.Footer>
</Box>
</Card.Root>
</Stack>
</Container>
);
}

42
src/lib/constants.js Normal file
View File

@@ -0,0 +1,42 @@
export const MENU_ITEMS = [
{
label: "Veterinarji",
icon: "",
href: "/veterinarji"
},
{
label: "Trgovine",
icon: "",
href: "/trgovine"
},
{
label: "Vzgoja",
icon: "",
href: "/vzgoja"
},
{
label: "Varstvo in sprehajanje",
icon: "",
href: "/varstvo-in-sprehajanje"
},{
label: "Frizerji",
icon: "",
href: "/frizerji"
},{
label: "Društva",
icon: "",
href: "/drustva"
},{
label: "Rejci živali",
icon: "",
href: "/rejci-zivali"
},{
label: "Zavetišča",
icon: "",
href: "/zavetisca"
},{
label: "Ostalo",
icon: "",
href: "/ostalo"
},
]

View File

@@ -0,0 +1,29 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}