initian commit
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
*.log
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal 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
34
docker-compose.yml
Normal 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
|
||||
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
telemetry: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
1944
package-lock.json
generated
1944
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -1,22 +1,34 @@
|
||||
{
|
||||
"name": "petadvisor",
|
||||
"name": "petadviser",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"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-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": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19"
|
||||
"@types/react-dom": "^19",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,46 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@import "leaflet/dist/leaflet.css";
|
||||
@import "leaflet.markercluster/dist/MarkerCluster.css";
|
||||
@import "leaflet.markercluster/dist/MarkerCluster.Default.css";
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
/*:root {*/
|
||||
/* --background: #ffffff;*/
|
||||
/* --foreground: #171717;*/
|
||||
/*}*/
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/*@media (prefers-color-scheme: dark) {*/
|
||||
/* :root {*/
|
||||
/* --background: #0a0a0a;*/
|
||||
/* --foreground: #ededed;*/
|
||||
/* }*/
|
||||
/*}*/
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
/*html,*/
|
||||
/*body {*/
|
||||
/* max-width: 100vw;*/
|
||||
/* overflow-x: hidden;*/
|
||||
/*}*/
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
/*body {*/
|
||||
/* color: var(--foreground);*/
|
||||
/* background: var(--background);*/
|
||||
/* font-family: Arial, Helvetica, sans-serif;*/
|
||||
/* -webkit-font-smoothing: antialiased;*/
|
||||
/* -moz-osx-font-smoothing: grayscale;*/
|
||||
/*}*/
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
/** {*/
|
||||
/* box-sizing: border-box;*/
|
||||
/* padding: 0;*/
|
||||
/* margin: 0;*/
|
||||
/*}*/
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
/*a {*/
|
||||
/* color: inherit;*/
|
||||
/* text-decoration: none;*/
|
||||
/*}*/
|
||||
|
||||
/*@media (prefers-color-scheme: dark) {*/
|
||||
/* html {*/
|
||||
/* color-scheme: dark;*/
|
||||
/* }*/
|
||||
/*}*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import type {Metadata} from "next";
|
||||
import {Geist, Geist_Mono} from "next/font/google";
|
||||
import "./globals.css";
|
||||
import {Provider} from "@/components/ui/provider"
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,19 +14,19 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
title: "Pet Adviser",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html suppressHydrationWarning lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
<Provider>{children}</Provider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
grid-row-start: 3;
|
||||
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();
|
||||
}
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
109
src/app/page.tsx
109
src/app/page.tsx
@@ -1,94 +1,39 @@
|
||||
import Image from "next/image";
|
||||
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() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header>
|
||||
<Header/>
|
||||
</header>
|
||||
<main className={styles.main}>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
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>
|
||||
<HeroBanner/>
|
||||
<CompaniesExplorer companies={seed} />
|
||||
<Articles />
|
||||
<Sponsor />
|
||||
<Calendar initialDate={new Date()} />
|
||||
</main>
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
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>
|
||||
);
|
||||
|
||||
35
src/components/Articles.js
Normal file
35
src/components/Articles.js
Normal 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
381
src/components/Calendar.js
Normal 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
43
src/components/Footer.js
Normal 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
77
src/components/Header.js
Normal 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>
|
||||
);
|
||||
}
|
||||
0
src/components/Header.module.css
Normal file
0
src/components/Header.module.css
Normal file
62
src/components/HeroBanner.js
Normal file
62
src/components/HeroBanner.js
Normal 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
0
src/components/Map.js
Normal file
147
src/components/Map/CompaniesExplorer.tsx
Normal file
147
src/components/Map/CompaniesExplorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
src/components/Map/CompanyMap.tsx
Normal file
151
src/components/Map/CompanyMap.tsx
Normal 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="© 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
59
src/components/Sponsor.js
Normal 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
42
src/lib/constants.js
Normal 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"
|
||||
},
|
||||
]
|
||||
29
src/utils/supabase/server.ts
Normal file
29
src/utils/supabase/server.ts
Normal 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.
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user