├── .nvmrc
├── .prettierignore
├── .eslintignore
├── src
├── lib
│ ├── auth.ts
│ ├── utils.ts
│ └── booking.ts
├── config
│ ├── email.ts
│ ├── defaults.ts
│ ├── db.ts
│ ├── fonts.ts
│ ├── admin.ts
│ ├── auth.ts
│ └── site.ts
├── components
│ ├── forms
│ │ ├── auth
│ │ │ ├── user-update-form.tsx
│ │ │ ├── password-reset-form.tsx
│ │ │ └── email-verification-form.tsx
│ │ ├── booking
│ │ │ └── booking-update-form.tsx
│ │ └── clinic
│ │ │ └── dates-unavailable-update-form.tsx
│ ├── sections
│ │ ├── gallery-section.tsx
│ │ ├── groomer-section.tsx
│ │ ├── contact-section.tsx
│ │ ├── team-section.tsx
│ │ ├── about-section.tsx
│ │ └── services-section.tsx
│ ├── emails
│ │ ├── booking
│ │ │ ├── booking-rejection-email.tsx
│ │ │ ├── booking-cancellation-email.tsx
│ │ │ ├── booking-confirmation-email.tsx
│ │ │ ├── booking-notification-for-arka-email.tsx
│ │ │ └── booking-notification-for-customer-email.tsx
│ │ ├── contact
│ │ │ ├── enquiry-notification-for-arka-email.tsx
│ │ │ └── enquiry-notification-for-customer-email.tsx
│ │ └── auth
│ │ │ ├── magic-link-email.tsx
│ │ │ ├── email-verification-email.tsx
│ │ │ └── reset-password-email.tsx
│ ├── ui
│ │ ├── aspect-ratio.tsx
│ │ ├── skeleton.tsx
│ │ ├── label.tsx
│ │ ├── textarea.tsx
│ │ ├── separator.tsx
│ │ ├── input.tsx
│ │ ├── toaster.tsx
│ │ ├── checkbox.tsx
│ │ ├── tooltip.tsx
│ │ ├── badge.tsx
│ │ ├── switch.tsx
│ │ ├── popover.tsx
│ │ ├── avatar.tsx
│ │ ├── alert.tsx
│ │ ├── scroll-area.tsx
│ │ ├── card.tsx
│ │ ├── accordion.tsx
│ │ ├── tabs.tsx
│ │ ├── calendar.tsx
│ │ ├── table.tsx
│ │ ├── button.tsx
│ │ └── dialog.tsx
│ ├── nav
│ │ ├── admin
│ │ │ ├── footer.tsx
│ │ │ ├── navigation.tsx
│ │ │ ├── clinic-tabs.tsx
│ │ │ └── navigation-mobile.tsx
│ │ └── landing
│ │ │ ├── navigation-mobile.tsx
│ │ │ ├── header.tsx
│ │ │ └── navigation.tsx
│ ├── google-map-widget.tsx
│ ├── auth
│ │ └── signout-button.tsx
│ ├── tailwind-indicator.tsx
│ ├── theme-toggle.tsx
│ ├── shells
│ │ └── shell.tsx
│ ├── header.tsx
│ ├── service-card.tsx
│ ├── password-input.tsx
│ ├── data-table
│ │ ├── data-table-view-options.tsx
│ │ ├── data-table-column-header.tsx
│ │ ├── data-table-loading.tsx
│ │ └── data-table-pagination.tsx
│ ├── page-header.tsx
│ ├── error-card.tsx
│ └── date-range-picker.tsx
├── app
│ ├── opengraph-image.png
│ ├── (admin)
│ │ └── admin
│ │ │ ├── rezerwacje
│ │ │ └── loading.tsx
│ │ │ ├── page.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── profil
│ │ │ ├── page.tsx
│ │ │ └── loading.tsx
│ │ │ ├── przychodnia
│ │ │ └── page.tsx
│ │ │ └── dostepnosc
│ │ │ └── page.tsx
│ ├── (landing)
│ │ ├── layout.tsx
│ │ ├── polityka-prywatnosci
│ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ └── rezerwacja
│ │ │ └── page.tsx
│ ├── api
│ │ ├── auth
│ │ │ └── [...nextauth]
│ │ │ │ └── route.ts
│ │ └── og
│ │ │ └── route.tsx
│ ├── (auth)
│ │ ├── layout.tsx
│ │ ├── logowanie
│ │ │ ├── haslo-reset
│ │ │ │ └── page.tsx
│ │ │ ├── page.tsx
│ │ │ └── haslo-aktualizacja
│ │ │ │ └── page.tsx
│ │ └── rejestracja
│ │ │ ├── potwierdz-email-ponownie
│ │ │ └── page.tsx
│ │ │ └── page.tsx
│ └── layout.tsx
├── db
│ ├── migrations
│ │ └── meta
│ │ │ └── _journal.json
│ └── prepared-statements
│ │ ├── auth.ts
│ │ ├── clinic.ts
│ │ ├── booking.ts
│ │ └── user.ts
├── hooks
│ ├── use-mounted.ts
│ └── use-debounce.ts
├── providers
│ ├── auth-provider.tsx
│ ├── theme-provider.tsx
│ └── smooth-scroll-provider.tsx
├── types
│ ├── next-auth.d.ts
│ └── index.d.ts
├── data
│ ├── constants.ts
│ ├── promo-text.ts
│ └── services.ts
├── validations
│ ├── og.ts
│ ├── availability.ts
│ ├── email.ts
│ ├── user.ts
│ ├── booking.ts
│ ├── clinic.ts
│ └── auth.ts
├── middleware.ts
├── auth.ts
├── styles
│ └── globals.css
└── actions
│ ├── user.ts
│ └── clinic.ts
├── public
├── favicon.ico
├── images
│ ├── logo.png
│ ├── doctors.png
│ ├── hero-image.png
│ ├── about-image.png
│ ├── location-marker.png
│ ├── services-image.png
│ ├── hero-bottom-wave.png
│ ├── screenshots
│ │ ├── screenshot_1.png
│ │ ├── screenshot_2.png
│ │ ├── screenshot_3.png
│ │ ├── screenshot_4.png
│ │ ├── screenshot_5.png
│ │ └── screenshot_6.png
│ ├── navbar-and-hero-background.png
│ └── svg
│ │ ├── footer-top-wave.svg
│ │ ├── gallery-top-wave.svg
│ │ ├── team-top-wave.svg
│ │ ├── about-bottom-wave.svg
│ │ ├── gallery-bottom-wave.svg
│ │ ├── team-bottom-wave.svg
│ │ └── radial-background.svg
├── fonts
│ ├── baloo-tamma.woff2
│ └── baloo-regular.woff2
├── vercel.svg
└── next.svg
├── postcss.config.mjs
├── drizzle.config.ts
├── components.json
├── .env.example
├── environment.d.ts
├── next.config.mjs
├── .gitignore
├── tsconfig.json
├── .prettierrc.json
├── .eslintrc.json
├── LICENSE
└── tailwind.config.ts
/.nvmrc:
--------------------------------------------------------------------------------
1 | v20.10.0
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | .next
4 | build
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | .next/
2 | node_modules/
3 | coverage/
4 | package-lock.json
5 | pacakge-lock.yaml
6 | pnpm-lock.yaml
--------------------------------------------------------------------------------
/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import { cache } from "react"
2 | import { auth } from "@/auth"
3 |
4 | export default cache(auth)
5 |
--------------------------------------------------------------------------------
/src/config/email.ts:
--------------------------------------------------------------------------------
1 | import { Resend } from "resend"
2 |
3 | export const resend = new Resend(process.env.RESEND_API_KEY)
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/logo.png
--------------------------------------------------------------------------------
/public/images/doctors.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/doctors.png
--------------------------------------------------------------------------------
/src/components/forms/auth/user-update-form.tsx:
--------------------------------------------------------------------------------
1 | // TODO
2 | export function UserUpdateForm(): JSX.Element {
3 | return
User Update Form
4 | }
5 |
--------------------------------------------------------------------------------
/public/images/hero-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/hero-image.png
--------------------------------------------------------------------------------
/src/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/src/app/opengraph-image.png
--------------------------------------------------------------------------------
/src/components/sections/gallery-section.tsx:
--------------------------------------------------------------------------------
1 | export function GallerySection(): JSX.Element {
2 | return
3 | }
4 |
--------------------------------------------------------------------------------
/public/fonts/baloo-tamma.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/fonts/baloo-tamma.woff2
--------------------------------------------------------------------------------
/public/images/about-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/about-image.png
--------------------------------------------------------------------------------
/src/components/sections/groomer-section.tsx:
--------------------------------------------------------------------------------
1 | export function GroomerSection(): JSX.Element {
2 | return
3 | }
4 |
--------------------------------------------------------------------------------
/public/fonts/baloo-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/fonts/baloo-regular.woff2
--------------------------------------------------------------------------------
/public/images/location-marker.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/location-marker.png
--------------------------------------------------------------------------------
/public/images/services-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/services-image.png
--------------------------------------------------------------------------------
/src/app/(admin)/admin/rezerwacje/loading.tsx:
--------------------------------------------------------------------------------
1 | export default function ClinicBookingsLoading(): JSX.Element {
2 | return TODO: Rezerwacje Wczytywanie
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/emails/booking/booking-rejection-email.tsx:
--------------------------------------------------------------------------------
1 | export function BookingRejectionEmail(): JSX.Element {
2 | return Booking Rejection Email
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/forms/booking/booking-update-form.tsx:
--------------------------------------------------------------------------------
1 | // TODO
2 | export function BookingUpdateForm(): JSX.Element {
3 | return Booking Update Form
4 | }
5 |
--------------------------------------------------------------------------------
/public/images/hero-bottom-wave.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/hero-bottom-wave.png
--------------------------------------------------------------------------------
/src/app/(landing)/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function LandingLayout({
2 | children,
3 | }: React.PropsWithChildren): JSX.Element {
4 | return {children}
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/emails/booking/booking-cancellation-email.tsx:
--------------------------------------------------------------------------------
1 | export function BookingCancelationEmail(): JSX.Element {
2 | return Bookin Cancelation Email
3 | }
4 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | }
7 |
8 | export default config
9 |
--------------------------------------------------------------------------------
/src/components/emails/booking/booking-confirmation-email.tsx:
--------------------------------------------------------------------------------
1 | export function BookingConfirmationEmail(): JSX.Element {
2 | return Booking Confirmation Email
3 | }
4 |
--------------------------------------------------------------------------------
/public/images/screenshots/screenshot_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/screenshots/screenshot_1.png
--------------------------------------------------------------------------------
/public/images/screenshots/screenshot_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/screenshots/screenshot_2.png
--------------------------------------------------------------------------------
/public/images/screenshots/screenshot_3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/screenshots/screenshot_3.png
--------------------------------------------------------------------------------
/public/images/screenshots/screenshot_4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/screenshots/screenshot_4.png
--------------------------------------------------------------------------------
/public/images/screenshots/screenshot_5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/screenshots/screenshot_5.png
--------------------------------------------------------------------------------
/public/images/screenshots/screenshot_6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/screenshots/screenshot_6.png
--------------------------------------------------------------------------------
/public/images/navbar-and-hero-background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System/HEAD/public/images/navbar-and-hero-background.png
--------------------------------------------------------------------------------
/src/config/defaults.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_SIGNIN_REDIRECT = "/admin/dostepnosc"
2 | export const DEFAULT_UNAUTHENTICATED_REDIRECT = "/logowanie"
3 | export const DEFAULT_SIGNOUT_REDIRECT = "/"
4 |
--------------------------------------------------------------------------------
/src/components/forms/clinic/dates-unavailable-update-form.tsx:
--------------------------------------------------------------------------------
1 | // TODO
2 | export function DatesUnavailableUpdateForm(): JSX.Element {
3 | return TODO: UpdateDatesUnavailableForm
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/emails/booking/booking-notification-for-arka-email.tsx:
--------------------------------------------------------------------------------
1 | export function BookingNotificationForArkaEmail(): JSX.Element {
2 | return Booking Notification For Arka Email
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/emails/booking/booking-notification-for-customer-email.tsx:
--------------------------------------------------------------------------------
1 | export function BookingNotificationForCustomerEmail(): JSX.Element {
2 | return Booking Notification For Customer Email
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/ui/aspect-ratio.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
4 |
5 | const AspectRatio = AspectRatioPrimitive.Root
6 |
7 | export { AspectRatio }
8 |
--------------------------------------------------------------------------------
/src/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | // import { siteConfig } from "@/config/site"
2 |
3 | export { GET, POST } from "@/auth"
4 |
5 | // export const runtime = "edge"
6 | // export const preferredRegion = siteConfig.hostingRegion
7 |
--------------------------------------------------------------------------------
/src/config/db.ts:
--------------------------------------------------------------------------------
1 | import { neon } from "@neondatabase/serverless"
2 | import { drizzle } from "drizzle-orm/neon-http"
3 |
4 | import * as schema from "@/db/schema"
5 |
6 | const sql = neon(process.env.DATABASE_URL)
7 |
8 | export const db = drizzle(sql, { schema })
9 |
--------------------------------------------------------------------------------
/public/images/svg/footer-top-wave.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/images/svg/gallery-top-wave.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/images/svg/team-top-wave.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "drizzle-kit"
2 |
3 | export default defineConfig({
4 | dialect: "postgresql",
5 | schema: "./src/db/schema/index.ts",
6 | out: "./src/db/migrations",
7 | dbCredentials: {
8 | url: process.env.DATABASE_URL,
9 | },
10 | })
11 |
--------------------------------------------------------------------------------
/src/db/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1725392607251,
9 | "tag": "0000_orange_rogue",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/public/images/svg/about-bottom-wave.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/config/fonts.ts:
--------------------------------------------------------------------------------
1 | import { Inter, JetBrains_Mono } from "next/font/google"
2 |
3 | export const fontInter = Inter({
4 | subsets: ["latin"],
5 | variable: "--font-sans",
6 | })
7 |
8 | export const fontJetBrainsMono = JetBrains_Mono({
9 | subsets: ["latin"],
10 | variable: "--font-mono",
11 | })
12 |
--------------------------------------------------------------------------------
/src/hooks/use-mounted.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | export function useMounted() {
4 | const [mounted, setMounted] = React.useState(false)
5 |
6 | React.useEffect(() => {
7 | setMounted(true)
8 |
9 | return () => setMounted(false)
10 | }, [])
11 |
12 | return mounted
13 | }
14 |
--------------------------------------------------------------------------------
/public/images/svg/gallery-bottom-wave.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/images/svg/team-bottom-wave.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/src/db/prepared-statements/auth.ts:
--------------------------------------------------------------------------------
1 | import { eq, sql } from "drizzle-orm"
2 |
3 | import { db } from "@/config/db"
4 | import { users } from "@/db/schema"
5 |
6 | export const psLinkOAuthAccount = db
7 | .update(users)
8 | .set({ emailVerified: new Date() })
9 | .where(eq(users.id, sql.placeholder("userId")))
10 | .prepare("psLinkOAuthAccount")
11 |
--------------------------------------------------------------------------------
/src/providers/auth-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { SessionProvider } from "next-auth/react"
5 |
6 | interface AuthProviderProps {
7 | children: React.ReactNode
8 | }
9 |
10 | export function AuthProvider({ children }: AuthProviderProps): JSX.Element {
11 | return {children}
12 | }
13 |
--------------------------------------------------------------------------------
/src/providers/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { ThemeProvider as NextThemesProvider } from "next-themes"
4 | import type { ThemeProviderProps } from "next-themes/dist/types"
5 |
6 | export function ThemeProvider({
7 | children,
8 | ...props
9 | }: ThemeProviderProps): JSX.Element {
10 | return {children}
11 | }
12 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/styles/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import type { DefaultSession } from "next-auth"
2 |
3 | type Role = "klient" | "administrator"
4 |
5 | declare module "next-auth" {
6 | interface User {
7 | role: Role
8 | }
9 |
10 | interface Session {
11 | user: User & DefaultSession["user"]
12 | }
13 | }
14 |
15 | declare module "@auth/core/adapters" {
16 | interface AdapterUser {
17 | role: Role
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/(landing)/polityka-prywatnosci/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next"
2 |
3 | export const metadata: Metadata = {
4 | metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL),
5 | title: "Polityka prywatności",
6 | description: "Polityka prywatności oraz klauzula RODO",
7 | }
8 |
9 | export default function PrivacyPolicyPage(): JSX.Element {
10 | return Polityka prywatności i RODO
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/nav/admin/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Shell } from "@/components/shells/shell"
2 | import { ThemeToggle } from "@/components/theme-toggle"
3 |
4 | export function Footer(): JSX.Element {
5 | return (
6 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/nav/landing/navigation-mobile.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { type NavItem } from "@/types"
4 |
5 | interface NavigationMobileProps {
6 | navItems: NavItem[]
7 | }
8 |
9 | // TODO
10 | export function NavigationMobile({
11 | navItems,
12 | }: NavigationMobileProps): JSX.Element {
13 | console.log("Under construction", navItems)
14 | return Navigation Mobile
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/(admin)/admin/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation"
2 |
3 | import { DEFAULT_UNAUTHENTICATED_REDIRECT } from "@/config/defaults"
4 |
5 | import auth from "@/lib/auth"
6 |
7 | export default async function AdminPage() {
8 | const session = await auth()
9 | session?.user
10 | ? redirect("/admin/dostepnosc")
11 | : redirect(DEFAULT_UNAUTHENTICATED_REDIRECT)
12 |
13 | return Admin Page
14 | }
15 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # APP
2 | NODE_ENV="development"
3 | NEXT_PUBLIC_APP_URL="https://localhost:3000"
4 |
5 | # AUTHENTICATION (NEXT-AUTH)
6 | NEXTAUTH_URL="https://localhost:3000"
7 | AUTH_SECRET=""
8 |
9 | # DATABASE (NEON)
10 | DATABASE_URL=""
11 |
12 | # EMAIL (RESEND)
13 | RESEND_API_KEY=""
14 | RESEND_EMAIL_FROM=""
15 | RESEND_EMAIL_TO=""
16 |
17 | # GOOGLE MAPS URL
18 | NEXT_PUBLIC_GOOGLE_MAPS_URL=""
19 | NEXT_PUBLIC_GOOGLE_API_KEY=""
20 |
--------------------------------------------------------------------------------
/src/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | interface AuthLayoutProps {
4 | children: React.ReactNode
5 | }
6 |
7 | export default function AuthLayout({ children }: AuthLayoutProps): JSX.Element {
8 | return (
9 |
10 | {children}
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/src/hooks/use-debounce.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | export function useDebounce(value: T, delay?: number): T {
4 | const [debouncedValue, setDebouncedValue] = React.useState(value)
5 |
6 | React.useEffect(() => {
7 | const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500)
8 |
9 | return () => {
10 | clearTimeout(timer)
11 | }
12 | }, [value, delay])
13 |
14 | return debouncedValue
15 | }
16 |
--------------------------------------------------------------------------------
/environment.d.ts:
--------------------------------------------------------------------------------
1 | import "next"
2 |
3 | declare global {
4 | namespace NodeJS {
5 | interface ProcessEnv {
6 | NODE_ENV: string
7 | NEXT_PUBLIC_APP_URL: string
8 | NEXTAUTH_URL: string
9 | AUTH_SECRET: string
10 | DATABASE_URL: string
11 | RESEND_API_KEY: string
12 | RESEND_EMAIL_FROM: string
13 | RESEND_EMAIL_TO: string
14 | GOOGLE_MAPS_URL: string
15 | GOOGLE_MAPS_API_KEY: string
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/providers/smooth-scroll-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { ReactLenis } from "@studio-freight/react-lenis"
5 |
6 | interface SmoothScrollProviderProps {
7 | children: React.ReactNode
8 | }
9 |
10 | export function SmoothScrollProvider({ children }: SmoothScrollProviderProps) {
11 | return (
12 |
13 | {children}
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/(admin)/admin/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Header } from "@/components/nav/admin/header"
2 |
3 | interface AdminLayoutProps {
4 | children: React.ReactNode
5 | }
6 |
7 | export default function AdminLayout({
8 | children,
9 | }: Readonly): JSX.Element {
10 | return (
11 |
12 |
13 |
14 | {children}
15 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/google-map-widget.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | export function GoogleMapWidget(): JSX.Element {
4 | return (
5 |
6 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/types/index.d.ts:
--------------------------------------------------------------------------------
1 | export interface NavItem {
2 | title: string
3 | href: string
4 | disabled?: boolean
5 | }
6 |
7 | export interface AdminNavItem extends NavItem {
8 | icon?: string
9 | }
10 |
11 | export interface Option {
12 | label: string
13 | value: string
14 | icon?: React.ComponentType<{ className?: string }>
15 | }
16 |
17 | export interface DataTableSearchableColumn {
18 | id: keyof TData
19 | title: string
20 | }
21 |
22 | export interface DataTableFilterableColumn
23 | extends DataTableSearchableColumn {
24 | options: Option[]
25 | }
26 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/config/admin.ts:
--------------------------------------------------------------------------------
1 | import { type AdminNavItem } from "@/types"
2 |
3 | export interface AdminConfigProps {
4 | sidebarNav: AdminNavItem[]
5 | }
6 |
7 | export const adminConfig: AdminConfigProps = {
8 | sidebarNav: [
9 | {
10 | title: "Przychodnia",
11 | href: "/admin/przychodnia",
12 | },
13 | {
14 | title: "Rezerwacje",
15 | href: "/admin/rezerwacje",
16 | },
17 | {
18 | title: "Dostępność",
19 | href: "/admin/dostepnosc",
20 | },
21 | {
22 | title: "Profil",
23 | href: "/admin/profil",
24 | },
25 | ] satisfies AdminNavItem[],
26 | }
27 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 |
2 | /** @type {import("next").NextConfig} */
3 | const nextConfig = {
4 | reactStrictMode: true,
5 | images: {
6 | remotePatterns: [
7 | {
8 | protocol: "https",
9 | hostname: "avatars.githubusercontent.com",
10 | },
11 | {
12 | protocol: "https",
13 | hostname: "lh3.googleusercontent.com",
14 | },
15 | {
16 | protocol: "https",
17 | hostname: "uploadthing.com",
18 | },
19 | {
20 | protocol: "https",
21 | hostname: "utfs.io",
22 | },
23 | ],
24 | },
25 | }
26 |
27 | export default nextConfig
28 |
--------------------------------------------------------------------------------
/src/data/constants.ts:
--------------------------------------------------------------------------------
1 | import { generateTimeOptions } from "@/lib/utils"
2 |
3 | export const TIME_INTERVAL = 30
4 | export const TIME_OPTIONS = generateTimeOptions(TIME_INTERVAL)
5 |
6 | export const DAYS_OF_WEEK = [
7 | "sunday",
8 | "monday",
9 | "tuesday",
10 | "wednesday",
11 | "thursday",
12 | "friday",
13 | "saturday",
14 | ] satisfies string[]
15 |
16 | export const DAY_MAPPINGS = {
17 | monday: "poniedziałek",
18 | tuesday: "wtorek",
19 | wednesday: "środa",
20 | thursday: "czwartek",
21 | friday: "piątek",
22 | saturday: "sobota",
23 | sunday: "niedziela",
24 | } satisfies Record
25 |
--------------------------------------------------------------------------------
/src/components/auth/signout-button.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { signOut } from "next-auth/react"
4 |
5 | import { Button } from "@/components/ui/button"
6 | import { Icons } from "@/components/icons"
7 |
8 | export function SignOutButton(): JSX.Element {
9 | return (
10 |
15 | void signOut({
16 | callbackUrl: "/",
17 | redirect: true,
18 | })
19 | }
20 | >
21 |
22 | Wyloguj
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.react-email/node_modules
6 | /.pnp
7 | .pnp.js
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /.react-email/.next/
15 | /out/
16 |
17 | # production
18 | /build
19 |
20 | # misc
21 | .DS_Store
22 | *.pem
23 |
24 | # debug
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 | .pnpm-debug.log*
29 |
30 | # local env files
31 | .env*.local
32 | .env
33 |
34 | # vercel
35 | .vercel
36 |
37 | # typescript
38 | *.tsbuildinfo
39 | next-env.d.ts
40 |
41 | # certificates
42 | certificates
43 |
44 | # notes
45 | TODO.md
--------------------------------------------------------------------------------
/src/validations/og.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod"
2 |
3 | export const ogImageSchema = z.object({
4 | title: z
5 | .string({
6 | required_error: "Title is required",
7 | invalid_type_error: "Title must be a string",
8 | })
9 | .max(60, "Title must be 60 characters or less"),
10 | description: z
11 | .string({
12 | invalid_type_error: "Description must be a string",
13 | })
14 | .max(1024, "Description must be 160 characters or less")
15 | .optional(),
16 | type: z
17 | .string({
18 | invalid_type_error: "Type must be a string",
19 | })
20 | .max(128, {
21 | message: "Type must be 128 characters or less",
22 | })
23 | .optional(),
24 | mode: z.enum(["light", "dark"]).default("dark"),
25 | })
26 |
--------------------------------------------------------------------------------
/src/db/prepared-statements/clinic.ts:
--------------------------------------------------------------------------------
1 | import { eq, sql } from "drizzle-orm"
2 |
3 | import { db } from "@/config/db"
4 | import { businessHours, clinics, datesUnavailable } from "@/db/schema"
5 |
6 | export const psGetClinic = db.select().from(clinics).prepare("psGetClinic")
7 |
8 | export const psGetBusinessHours = db
9 | .select()
10 | .from(businessHours)
11 | .prepare("psGetBusinessHours")
12 |
13 | export const psGetDatesUnavailable = db
14 | .select()
15 | .from(datesUnavailable)
16 | .prepare("psGetDatesUnavailable")
17 |
18 | export const psCheckIfClinicExists = db.query.clinics
19 | .findFirst({
20 | columns: {
21 | id: true,
22 | },
23 | where: eq(clinics.id, sql.placeholder("id")),
24 | })
25 | .prepare("psCheckIfClinicExists")
26 |
--------------------------------------------------------------------------------
/public/images/svg/radial-background.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/tailwind-indicator.tsx:
--------------------------------------------------------------------------------
1 | export function TailwindIndicator(): JSX.Element | null {
2 | if (process.env.NODE_ENV === "production") return null
3 |
4 | return (
5 |
6 |
xs
7 |
8 | sm
9 |
10 |
md
11 |
lg
12 |
xl
13 |
2xl
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/src/data/promo-text.ts:
--------------------------------------------------------------------------------
1 | export const aboutSectionParagraphs = [
2 | {
3 | content:
4 | "Przychodnia weterynaryjna Arka działa nieprzerwanie na terenie Bochni i okolic od 1997 roku, dbając o zdrowie i dobre samopoczucie Państwa zwierząt.",
5 | },
6 | {
7 | content:
8 | "W naszej przychodni można liczyć nie tylko na fachową pomoc, ale i na pełne zaangażowanie. Arka to doświadczeni lekarze, nowoczesny sprzęt i opieka na najwyższym poziomie.",
9 | },
10 | {
11 | content:
12 | "Nic więc dziwnego, że przychodnia jest tak często wybierana przez klientów.",
13 | },
14 | ]
15 |
16 | export const servicesSectionParagraphs = [
17 | {
18 | content:
19 | "Dzięki bogatemu wyposażeniu przychodni, możemy zaoferować szeroki wachlarz usług i profesjonalnie je wykonać. W przypadku niektorych usług, możliwe również wizyty domowe.",
20 | },
21 | ]
22 |
--------------------------------------------------------------------------------
/src/components/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 |
5 | import { Button } from "@/components/ui/button"
6 | import { Icons } from "@/components/icons"
7 |
8 | export function ThemeToggle(): JSX.Element {
9 | const { setTheme, theme } = useTheme()
10 |
11 | return (
12 | setTheme(theme === "light" ? "dark" : "light")}
16 | >
17 |
21 |
25 | Toggle theme
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "ESNext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "ESNext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "noUncheckedIndexedAccess": true,
18 | "baseUrl": ".",
19 | "plugins": [
20 | {
21 | "name": "next"
22 | }
23 | ],
24 | "paths": {
25 | "@/*": ["./src/*"]
26 | }
27 | },
28 | "include": [
29 | "**/*.ts",
30 | "**/*.tsx",
31 | "**/*.mjs",
32 | "next-env.d.ts",
33 | ".next/types/**/*.ts",
34 | ],
35 | "exclude": ["node_modules"]
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export type InputProps = React.InputHTMLAttributes
6 |
7 | const Input = React.forwardRef(
8 | ({ className, type, ...props }, ref) => {
9 | return (
10 |
19 | )
20 | }
21 | )
22 | Input.displayName = "Input"
23 |
24 | export { Input }
25 |
--------------------------------------------------------------------------------
/src/app/(landing)/page.tsx:
--------------------------------------------------------------------------------
1 | import { getClinic } from "@/actions/clinic"
2 |
3 | import { Footer } from "@/components/nav/landing/footer"
4 | import { AboutSection } from "@/components/sections/about-section"
5 | import { HeroSection } from "@/components/sections/hero-section"
6 | import { ServicesSection } from "@/components/sections/services-section"
7 | import { TeamSection } from "@/components/sections/team-section"
8 |
9 | export default async function Home(): Promise {
10 | const clinic = await getClinic()
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/shells/shell.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const shellVariants = cva("grid items-center", {
7 | variants: {
8 | variant: {
9 | default: "container gap-8 pb-8 pt-6 md:py-8",
10 | centered: "mx-auto mb-16 mt-20 max-w-md justify-center",
11 | sidebar: "",
12 | },
13 | },
14 | defaultVariants: {
15 | variant: "default",
16 | },
17 | })
18 |
19 | interface ShellProps
20 | extends React.HTMLAttributes,
21 | VariantProps {
22 | as?: React.ElementType
23 | }
24 |
25 | function Shell({
26 | className,
27 | as: Comp = "section",
28 | variant,
29 | ...props
30 | }: ShellProps): JSX.Element {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Shell, shellVariants }
37 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useToast } from "@/hooks/use-toast"
4 |
5 | import {
6 | Toast,
7 | ToastClose,
8 | ToastDescription,
9 | ToastProvider,
10 | ToastTitle,
11 | ToastViewport,
12 | } from "@/components/ui/toast"
13 |
14 | export function Toaster() {
15 | const { toasts } = useToast()
16 |
17 | return (
18 |
19 | {toasts.map(function ({ id, title, description, action, ...props }) {
20 | return (
21 |
22 |
23 | {title && {title} }
24 | {description && (
25 | {description}
26 | )}
27 |
28 | {action}
29 |
30 |
31 | )
32 | })}
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/emails/contact/enquiry-notification-for-arka-email.tsx:
--------------------------------------------------------------------------------
1 | import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"
2 |
3 | interface EnquiryNotificationForArkaEmailProps {
4 | firstName: string
5 | lastName: string
6 | email: string
7 | phone: string
8 | message?: string
9 | }
10 |
11 | export function EnquiryNotificationForArkaEmail({
12 | firstName,
13 | lastName,
14 | }: EnquiryNotificationForArkaEmailProps): JSX.Element {
15 | const previewText = `${firstName} ${lastName} przesyła zapytanie z formularza kontaktowego>`
16 |
17 | return (
18 |
19 |
20 | Zapytanie z formularza kontatowego
21 | {previewText}
22 |
23 |
24 |
25 | {/* TODO */}
26 | Nowe zapytanie z formularza kontaktowego przeslane do przychodni ARKA
27 |
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/header.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | interface HeaderProps extends React.HTMLAttributes {
4 | title: string
5 | description?: string | null
6 | size?: "default" | "sm"
7 | }
8 |
9 | export function Header({
10 | title,
11 | description,
12 | size = "default",
13 | className,
14 | ...props
15 | }: HeaderProps): JSX.Element {
16 | return (
17 |
18 |
24 | {title}
25 |
26 | {description ? (
27 |
33 | {description}
34 |
35 | ) : null}
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "endOfLine": "lf",
3 | "semi": false,
4 | "singleQuote": false,
5 | "tabWidth": 2,
6 | "trailingComma": "es5",
7 | "importOrder": [
8 | "^@/styles/(.*)$",
9 | "",
10 | "^(react/(.*)$)|^(react$)",
11 | "^(next/(.*)$)|^(next$)",
12 | "",
13 | "",
14 | "^@/env.mjs",
15 | "^types$",
16 | "^@/types/(.*)$",
17 | "^@/config/(.*)$",
18 | "^@/db/(.*)$",
19 | "^@/validations/(.*)$",
20 | "^@/data/(.*)$",
21 | "",
22 | "^@/providers/(.*)$",
23 | "^@/hooks/(.*)$",
24 | "^@/lib/(.*)$",
25 | "",
26 | "^@/components/ui/(.*)$",
27 | "^@/components/(.*)$",
28 | "^@/app/(.*)$",
29 | "",
30 | "^[./]"
31 | ],
32 | "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"],
33 | "tailwindAttributes": ["tw"],
34 | "tailwindFunctions": ["cva"],
35 | "plugins": [
36 | "@ianvs/prettier-plugin-sort-imports",
37 | "prettier-plugin-tailwindcss"
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/emails/contact/enquiry-notification-for-customer-email.tsx:
--------------------------------------------------------------------------------
1 | import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"
2 |
3 | interface EnquiryNotificationForCustomerEmailProps {
4 | firstName: string
5 | lastName: string
6 | email: string
7 | phone: string
8 | message?: string
9 | }
10 |
11 | export function EnquiryNotificationForCustomerEmail({
12 | firstName,
13 | lastName,
14 | }: EnquiryNotificationForCustomerEmailProps): JSX.Element {
15 | const previewText = `${firstName} ${lastName} przesyła zapytanie z formularza kontaktowego>`
16 |
17 | return (
18 |
19 |
20 | Zapytanie z formularza kontatowego
21 | {previewText}
22 |
23 |
24 |
25 | {/* TODO */}
26 | Nowe zapytanie z formularza kontaktowego przeslane do przychodni ARKA
27 |
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/src/config/auth.ts:
--------------------------------------------------------------------------------
1 | import { getUserByEmail } from "@/actions/user"
2 | import bcryptjs from "bcryptjs"
3 | import type { NextAuthConfig } from "next-auth"
4 | import CredentialsProvider from "next-auth/providers/credentials"
5 |
6 | import { signInWithPasswordSchema } from "@/validations/auth"
7 |
8 | export default {
9 | providers: [
10 | CredentialsProvider({
11 | async authorize(rawCredentials) {
12 | const validatedCredentials =
13 | signInWithPasswordSchema.safeParse(rawCredentials)
14 |
15 | if (validatedCredentials.success) {
16 | const user = await getUserByEmail(validatedCredentials.data.email)
17 | if (!user || !user.passwordHash) return null
18 |
19 | const passwordIsValid = await bcryptjs.compare(
20 | validatedCredentials.data.password,
21 | user.passwordHash
22 | )
23 |
24 | if (passwordIsValid) return user
25 | }
26 | return null
27 | },
28 | }),
29 | ],
30 | } satisfies NextAuthConfig
31 |
--------------------------------------------------------------------------------
/src/db/prepared-statements/booking.ts:
--------------------------------------------------------------------------------
1 | import { eq, sql } from "drizzle-orm"
2 |
3 | import { db } from "@/config/db"
4 | import { bookings } from "@/db/schema"
5 |
6 | export const psGetAllDoctorBookings = db
7 | .select()
8 | .from(bookings)
9 | .where(eq(bookings.type, "weterynarz"))
10 | .prepare("psGetAllDoctorBookings")
11 |
12 | export const psGetAllGroomerBookings = db
13 | .select()
14 | .from(bookings)
15 | .where(eq(bookings.type, "salon fryzur"))
16 | .prepare("psGetAllGroomerBookings")
17 |
18 | export const psGetAllBookings = db
19 | .select()
20 | .from(bookings)
21 | .prepare("psGetAllBookings")
22 |
23 | export const psDeleteBookingById = db
24 | .delete(bookings)
25 | .where(eq(bookings.id, sql.placeholder("id")))
26 | .prepare("psDeleteBookingById")
27 |
28 | export const psCheckIfBookingExists = db.query.bookings
29 | .findFirst({
30 | columns: {
31 | id: true,
32 | },
33 | where: eq(bookings.id, sql.placeholder("id")),
34 | })
35 | .prepare("psCheckIfBookingExists")
36 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | {
3 | "parser": "@typescript-eslint/parser",
4 | "parserOptions": {
5 | "project": true
6 | },
7 | "extends": [
8 | "next",
9 | "next/core-web-vitals",
10 | "eslint:recommended",
11 | "plugin:@typescript-eslint/recommended",
12 | "plugin:tailwindcss/recommended",
13 | "prettier"
14 | ],
15 | "plugins": ["@typescript-eslint", "tailwindcss"],
16 | "rules": {
17 | "@typescript-eslint/no-empty-object-type": "off",
18 | "@typescript-eslint/consistent-type-imports": [
19 | "warn",
20 | {
21 | "prefer": "type-imports",
22 | "fixStyle": "inline-type-imports"
23 | }
24 | ],
25 | "@typescript-eslint/no-unused-vars": [
26 | "warn",
27 | { "argsIgnorePattern": "^_" }
28 | ],
29 | "@next/next/no-img-element": "off"
30 | },
31 | "settings": {
32 | "next": {
33 | "rootDir": "./src"
34 | },
35 | "tailwindcss": {
36 | "callees": ["cn", "cva"],
37 | "config": "./tailwind.config.ts",
38 | "classRegex": "^(class(Name)?|tw)$"
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023, 2024 Piotr Borowiecki
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5 | import { CheckIcon } from "@radix-ui/react-icons"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ))
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
29 |
30 | export { Checkbox }
31 |
--------------------------------------------------------------------------------
/src/db/prepared-statements/user.ts:
--------------------------------------------------------------------------------
1 | import { eq, sql } from "drizzle-orm"
2 |
3 | import { db } from "@/config/db"
4 | import { users } from "@/db/schema"
5 |
6 | export const psGetUserById = db
7 | .select()
8 | .from(users)
9 | .where(eq(users.id, sql.placeholder("id")))
10 | .prepare("psGetUserById")
11 |
12 | export const psGetUserByEmail = db
13 | .select()
14 | .from(users)
15 | .where(eq(users.email, sql.placeholder("email")))
16 | .prepare("psGetUserByEmail")
17 |
18 | export const psGetUserByEmailVerificationToken = db
19 | .select()
20 | .from(users)
21 | .where(
22 | eq(users.emailVerificationToken, sql.placeholder("emailVerificationToken"))
23 | )
24 | .prepare("psGetUserByEmailVerificationToken")
25 |
26 | export const psGetUserByResetPasswordToken = db
27 | .select()
28 | .from(users)
29 | .where(eq(users.resetPasswordToken, sql.placeholder("resetPasswordToken")))
30 | .prepare("psGetUserByResetPasswordToken")
31 |
32 | export const psCheckIfUserExists = db.query.users
33 | .findFirst({
34 | columns: {
35 | id: true,
36 | },
37 | where: eq(users.id, sql.placeholder("id")),
38 | })
39 | .prepare("psCheckIfUserExists")
40 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/src/app/(admin)/admin/profil/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next"
2 | import { redirect } from "next/navigation"
3 |
4 | import { DEFAULT_UNAUTHENTICATED_REDIRECT } from "@/config/defaults"
5 |
6 | import auth from "@/lib/auth"
7 |
8 | import { UserUpdateForm } from "@/components/forms/auth/user-update-form"
9 | import {
10 | PageHeader,
11 | PageHeaderDescription,
12 | PageHeaderHeading,
13 | } from "@/components/page-header"
14 | import { Shell } from "@/components/shells/shell"
15 |
16 | export const metadata: Metadata = {
17 | metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL),
18 | title: "Profil",
19 | description: "Zarządzaj danymi administratora",
20 | }
21 |
22 | export default async function ProfilePage(): Promise {
23 | const session = await auth()
24 | if (!session) redirect(DEFAULT_UNAUTHENTICATED_REDIRECT)
25 |
26 | return (
27 |
28 |
29 | Profil
30 |
31 | Zarządzanie danymi administratora
32 |
33 |
34 |
35 |
36 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/src/components/nav/admin/navigation.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import Link from "next/link"
5 | import type { NavItem } from "@/types"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | import {
10 | NavigationMenu,
11 | NavigationMenuItem,
12 | NavigationMenuLink,
13 | NavigationMenuList,
14 | navigationMenuTriggerStyle,
15 | } from "@/components/ui/navigation-menu"
16 |
17 | interface NavigationProps {
18 | items?: NavItem[]
19 | }
20 |
21 | export function Navigation({ items }: NavigationProps): JSX.Element {
22 | return (
23 |
24 |
25 | {items?.map((item) => (
26 |
31 |
32 |
36 | {item.title}
37 |
38 |
39 |
40 | ))}
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from "next-auth"
2 |
3 | import authConfig from "@/config/auth"
4 | import {
5 | DEFAULT_SIGNIN_REDIRECT,
6 | DEFAULT_UNAUTHENTICATED_REDIRECT,
7 | } from "@/config/defaults"
8 |
9 | const { auth } = NextAuth(authConfig)
10 |
11 | export const authRoutes = ["/logowanie", "/rejestracja", "/error"]
12 | export const publicRoutes = [
13 | "/",
14 | "/polityka-prywatnosci",
15 | "/rezerwacja",
16 | "/rejestracja/potwierdz-email",
17 | "/rejestracja/potwierdz-email-ponownie",
18 | "/logowanie/haslo-reset",
19 | "/logowanie/haslo-aktualizacja",
20 | ]
21 |
22 | export default auth((req) => {
23 | const authenticated = !!req.auth
24 | const isApiAuthRoute = req.nextUrl.pathname.startsWith("/api/auth")
25 | const isAuthRoute = authRoutes.includes(req.nextUrl.pathname)
26 | const isPublicRoute = publicRoutes.includes(req.nextUrl.pathname)
27 |
28 | if (isApiAuthRoute) return null
29 |
30 | if (isAuthRoute) {
31 | if (authenticated) {
32 | return Response.redirect(new URL(DEFAULT_SIGNIN_REDIRECT, req.nextUrl))
33 | }
34 | return null
35 | }
36 |
37 | if (!authenticated && !isPublicRoute) {
38 | return Response.redirect(
39 | new URL(DEFAULT_UNAUTHENTICATED_REDIRECT, req.nextUrl)
40 | )
41 | }
42 |
43 | return null
44 | })
45 |
46 | export const config = {
47 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
48 | }
49 |
--------------------------------------------------------------------------------
/src/app/(admin)/admin/przychodnia/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next"
2 | import { redirect } from "next/navigation"
3 | import { getClinic } from "@/actions/clinic"
4 |
5 | import { DEFAULT_UNAUTHENTICATED_REDIRECT } from "@/config/defaults"
6 |
7 | import auth from "@/lib/auth"
8 |
9 | import { ClinicUpdateForm } from "@/components/forms/clinic/clinic-update-form"
10 | import {
11 | PageHeader,
12 | PageHeaderDescription,
13 | PageHeaderHeading,
14 | } from "@/components/page-header"
15 | import { Shell } from "@/components/shells/shell"
16 |
17 | export const metadata: Metadata = {
18 | metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL),
19 | title: "Przychodnia",
20 | description: "Zarządzaj danymi przychodni",
21 | }
22 |
23 | export default async function ClinicPage(): Promise {
24 | const session = await auth()
25 | if (!session) redirect(DEFAULT_UNAUTHENTICATED_REDIRECT)
26 |
27 | const clinic = await getClinic()
28 |
29 | return (
30 |
31 |
32 | Przychodnia
33 |
34 | Zarządzanie danymi przychodni
35 |
36 |
37 |
38 |
39 |
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/(admin)/admin/profil/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton"
2 | import {
3 | PageHeader,
4 | PageHeaderDescription,
5 | PageHeaderHeading,
6 | } from "@/components/page-header"
7 | import { Shell } from "@/components/shells/shell"
8 |
9 | export default function ProfileLoading(): JSX.Element {
10 | return (
11 |
12 |
13 | Profil
14 |
15 | Zarządzanie danymi administratora
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/src/auth.ts:
--------------------------------------------------------------------------------
1 | import { linkOAuthAccount } from "@/actions/auth"
2 | import { getUserById } from "@/actions/user"
3 | import { DrizzleAdapter } from "@auth/drizzle-adapter"
4 | import NextAuth from "next-auth"
5 |
6 | import authConfig from "@/config/auth"
7 | import { db } from "@/config/db"
8 |
9 | export const {
10 | handlers: { GET, POST },
11 | auth,
12 | signIn,
13 | signOut,
14 | } = NextAuth({
15 | debug: process.env.NODE_ENV === "development",
16 | pages: {
17 | signIn: "/logowanie",
18 | signOut: "/signout",
19 | },
20 | secret: process.env.AUTH_SECRET,
21 | session: {
22 | strategy: "jwt",
23 | maxAge: 30 * 24 * 60 * 60, // 30 days
24 | updateAge: 24 * 60 * 60, // 24 hours
25 | },
26 | events: {
27 | async linkAccount({ user }) {
28 | if (user.id) await linkOAuthAccount({ userId: user.id })
29 | },
30 | },
31 | callbacks: {
32 | jwt({ token, user }) {
33 | if (user) token.role = user.role
34 | return token
35 | },
36 | session({ session, token }) {
37 | session.user.role = token.role as "klient" | "administrator"
38 | return session
39 | },
40 | async signIn({ user, account }) {
41 | if (!user.id) return false
42 | if (account?.provider !== "credentials") return true
43 |
44 | const existingUser = await getUserById({ id: user.id })
45 |
46 | return !existingUser?.emailVerified ? false : true
47 | },
48 | },
49 | adapter: DrizzleAdapter(db),
50 | ...authConfig,
51 | })
52 |
--------------------------------------------------------------------------------
/src/components/service-card.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
2 | import { Icons } from "@/components/icons"
3 |
4 | interface ServiceCardProps {
5 | service: {
6 | title: string
7 | description?: string
8 | bulletPoints?: string[]
9 | }
10 | }
11 |
12 | export function ServiceCard({ service }: ServiceCardProps): JSX.Element {
13 | return (
14 |
15 |
16 |
17 |
18 | {service.title}
19 |
20 |
21 |
22 | {service.bulletPoints ? (
23 |
24 | {service.bulletPoints?.map((bulletPoint, index) => (
25 | {bulletPoint}
26 | ))}
27 |
28 | ) : (
29 | {service.description}
30 | )}
31 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/password-input.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { EyeNoneIcon, EyeOpenIcon } from "@radix-ui/react-icons"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | import { Button } from "@/components/ui/button"
9 | import { Input, type InputProps } from "@/components/ui/input"
10 |
11 | const PasswordInput = React.forwardRef(
12 | ({ className, ...props }, ref) => {
13 | const [showPassword, setShowPassword] = React.useState(false)
14 |
15 | return (
16 |
17 |
23 | setShowPassword((prev) => !prev)}
29 | disabled={props.value === "" || props.disabled}
30 | >
31 | {showPassword ? (
32 |
33 | ) : (
34 |
35 | )}
36 |
37 | {showPassword ? "Hide password" : "Show password"}
38 |
39 |
40 |
41 | )
42 | }
43 | )
44 | PasswordInput.displayName = "PasswordInput"
45 |
46 | export { PasswordInput }
47 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/src/validations/availability.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod"
2 |
3 | export const hourSchema = z
4 | .string({
5 | required_error: "Podaj godzinę otwarcia",
6 | invalid_type_error: "Nieprawidłowy typ danych",
7 | })
8 | .length(5)
9 | .regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, {
10 | message: "Nieprawidłowy format godziny. Poprawny format to HH:MM",
11 | })
12 | .nullable()
13 |
14 | export const businessHoursIdSchema = z
15 | .string({
16 | required_error: "Id jest wymagane",
17 | invalid_type_error: "Dane wejściowe muszą być tekstem",
18 | })
19 | .min(1, {
20 | message: "Id musi mieć przynajmniej 1 znak",
21 | })
22 | .max(128, {
23 | message: "Id może mieć maksymalnie 32 znaki",
24 | })
25 |
26 | export const singlePeriodSchema = z.object({
27 | opening: hourSchema,
28 | closing: hourSchema,
29 | })
30 |
31 | export const dayPeriodsSchema = z.array(singlePeriodSchema).default([])
32 |
33 | export const businessHoursSchema = z.object({
34 | mondayPeriods: dayPeriodsSchema,
35 | tuesdayPeriods: dayPeriodsSchema,
36 | wednesdayPeriods: dayPeriodsSchema,
37 | thursdayPeriods: dayPeriodsSchema,
38 | fridayPeriods: dayPeriodsSchema,
39 | saturdayPeriods: dayPeriodsSchema,
40 | sundayPeriods: dayPeriodsSchema,
41 | })
42 |
43 | export const addBusinessHoursSchema = businessHoursSchema
44 |
45 | export const updateBusinessHoursSchema = businessHoursSchema.extend({
46 | id: businessHoursIdSchema,
47 | })
48 |
49 | export type AddBusinessHoursInput = z.infer
50 |
51 | export type UpdateBusinessHoursInput = z.infer
52 |
--------------------------------------------------------------------------------
/src/app/(auth)/logowanie/haslo-reset/page.tsx:
--------------------------------------------------------------------------------
1 | import { type Metadata } from "next"
2 | import Link from "next/link"
3 |
4 | import { buttonVariants } from "@/components/ui/button"
5 | import {
6 | Card,
7 | CardContent,
8 | CardDescription,
9 | CardHeader,
10 | CardTitle,
11 | } from "@/components/ui/card"
12 | import { PasswordResetForm } from "@/components/forms/auth/password-reset-form"
13 |
14 | export const metadata: Metadata = {
15 | metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL),
16 | title: "Resetowanie hasła",
17 | description: "Podaj adres email aby otrzymać link do zresetowania hasła",
18 | }
19 |
20 | export default function PasswordReset(): JSX.Element {
21 | return (
22 |
23 |
24 |
25 | Resetowanie hasła
26 |
27 | Link zostanie wysłany na wskazany adres
28 |
29 |
30 |
31 |
32 |
37 | Anuluj
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/nav/landing/header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 |
3 | import { siteConfig } from "@/config/site"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | import { buttonVariants } from "@/components/ui/button"
8 | import { Navigation } from "@/components/nav/landing/navigation"
9 | import { NavigationMobile } from "@/components/nav/landing/navigation-mobile"
10 |
11 | export function Header(): JSX.Element {
12 | return (
13 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/(auth)/rejestracja/potwierdz-email-ponownie/page.tsx:
--------------------------------------------------------------------------------
1 | import { type Metadata } from "next"
2 | import Link from "next/link"
3 |
4 | import { buttonVariants } from "@/components/ui/button"
5 | import {
6 | Card,
7 | CardContent,
8 | CardDescription,
9 | CardHeader,
10 | CardTitle,
11 | } from "@/components/ui/card"
12 | import { EmailVerificationForm } from "@/components/forms/auth/email-verification-form"
13 |
14 | export const metadata: Metadata = {
15 | metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL),
16 | title: "Weryfikacja maila",
17 | description: "Podaj email aby otrzymać link weryfikacyjny",
18 | }
19 |
20 | export default function ReverifyEmailPage(): JSX.Element {
21 | return (
22 |
23 |
24 |
25 | Weryfikacja maila
26 |
27 | Podaj email aby otrzymać link weryfikacyjny
28 |
29 |
30 |
31 |
32 |
37 |
38 | Anuluj prośbę o wysłanie linka weryfikacyjnego
39 |
40 | Anuluj
41 |
42 |
43 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/nav/landing/navigation.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Link from "next/link"
4 | import { type NavItem } from "@/types"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | import {
9 | NavigationMenu,
10 | NavigationMenuItem,
11 | NavigationMenuLink,
12 | NavigationMenuList,
13 | navigationMenuTriggerStyle,
14 | } from "@/components/ui/navigation-menu"
15 |
16 | interface NavigationProps {
17 | navItems: NavItem[]
18 | }
19 |
20 | export function Navigation({ navItems }: NavigationProps): JSX.Element {
21 | return (
22 |
23 |
24 | {navItems.map((item) => (
25 |
26 |
27 |
36 | {item.title}
37 |
38 |
39 |
40 | ))}
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/sections/contact-section.tsx:
--------------------------------------------------------------------------------
1 | import { ContactForm } from "@/components/forms/contact-form"
2 | import { GoogleMapWidget } from "@/components/google-map-widget"
3 |
4 | export function ContactSection(): JSX.Element {
5 | return (
6 |
10 |
15 |
16 |
17 |
18 | Pomoc i opieka
19 |
20 |
21 |
22 | na wyciągnięcie ręki
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef & {
11 | orientation?: "vertical" | "horizontal"
12 | }
13 | >(({ className, children, orientation, ...props }, ref) => (
14 |
19 |
20 | {children}
21 |
22 |
23 |
24 |
25 | ))
26 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
27 |
28 | const ScrollBar = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, orientation = "vertical", ...props }, ref) => (
32 |
45 |
46 |
47 | ))
48 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
49 |
50 | export { ScrollArea, ScrollBar }
51 |
--------------------------------------------------------------------------------
/src/app/(landing)/rezerwacja/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 | import {
3 | getBusinessHours,
4 | getDatesUnavailableAsAnArrayOfDates,
5 | } from "@/actions/availability"
6 | import { getAllBookings } from "@/actions/booking"
7 | import Balancer from "react-wrap-balancer"
8 |
9 | import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
10 | import { BookingAddForm } from "@/components/forms/booking/booking-add-form"
11 |
12 | export default async function AddBookingPage(): Promise {
13 | const businessHours = await getBusinessHours()
14 | const datesUnavailable = await getDatesUnavailableAsAnArrayOfDates()
15 | const existingBookings = await getAllBookings()
16 |
17 | return (
18 |
19 |
20 |
21 | Nowa rezerwacja
22 |
23 |
24 |
29 |
30 |
31 |
32 |
33 | Wysyłając formularz, wyrażasz zgodę na przetwarzanie swoich danych
34 | osobowych w celu realizacji usługi, zgodnie z{" "}
35 |
39 | klauzulą Rodo
40 |
41 | .
42 |
43 |
44 |
45 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/src/config/site.ts:
--------------------------------------------------------------------------------
1 | import { type AdminNavItem, type NavItem } from "@/types"
2 |
3 | const links = {
4 | facebook: "",
5 | github:
6 | "https://github.com/pjborowiecki/ARKA-Veterinary-Clinic-Page-and-Appointment-Booking-System.git",
7 | openGraphImage: "https://arka-weterynaria.pl/opengraph-image.png",
8 | manifestFile: "https://saasyland.com/site.webmanifest",
9 | authorsWebsite: "https://pjborowiecki.com",
10 | }
11 |
12 | export const siteConfig = {
13 | links,
14 | nameShort: "ARKA",
15 | nameLong: "Przychodnia weterynaryjna ARKA w Bochni",
16 | description: "",
17 | url: "https://arka-weterynaria.pl",
18 | ogImage: links.openGraphImage,
19 | author: "Piotr Borowiecki",
20 | hostingRegion: "fra1",
21 | keywords: [
22 | "Przychodnia weterynaryjna",
23 | "Weterynarz",
24 | "Weterynaria",
25 | "Weterynaria Bochnia",
26 | "Weterynaria Brzesko",
27 | "Weterynaria Małopolska",
28 | "ARKA",
29 | "Piotr Surma",
30 | ],
31 | mainNavItems: [
32 | {
33 | title: "Przychodnia",
34 | href: "/#przychodnia",
35 | },
36 | {
37 | title: "Usługi",
38 | href: "/#uslugi",
39 | },
40 | {
41 | title: "Personel",
42 | href: "/#personel",
43 | },
44 | // {
45 | // title: "Salon fryzur",
46 | // href: "/#salon-fryzur",
47 | // },
48 | // {
49 | // title: "Galeria",
50 | // href: "/#galeria",
51 | // },
52 | {
53 | title: "Kontakt",
54 | href: "/#kontakt",
55 | },
56 | ] satisfies NavItem[],
57 |
58 | mobileNav: [
59 | {
60 | title: "Przychodnia",
61 | href: "/admin/przychodnia",
62 | },
63 | {
64 | title: "Rezerwacje",
65 | href: "/admin/rezerwacje",
66 | },
67 | {
68 | title: "Dostępność",
69 | href: "/admin/dostepnosc",
70 | },
71 | {
72 | title: "Profil",
73 | href: "/admin/profil",
74 | },
75 | ] satisfies AdminNavItem[],
76 | }
77 |
--------------------------------------------------------------------------------
/src/validations/email.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod"
2 |
3 | export const emailSchema = z
4 | .string({
5 | required_error: "Email jest wymagany",
6 | invalid_type_error: "Nieprawidłowy typ danych",
7 | })
8 | .min(5, {
9 | message: "Email musi składać się z przynamniej 5 znaków",
10 | })
11 | .max(64, {
12 | message: "Email nie może mieć więcej ni 64 znaki",
13 | })
14 | .email({
15 | message: "Proszę podać poprawny adres email",
16 | })
17 |
18 | export const emailVerificationSchema = z.object({
19 | email: emailSchema,
20 | })
21 |
22 | export const markEmailAsVerifiedSchema = z.object({
23 | token: z.string(),
24 | })
25 |
26 | export const checkIfEmailVerifiedSchema = z.object({
27 | email: emailSchema,
28 | })
29 |
30 | export const contactFormSchema = z.object({
31 | email: emailSchema,
32 | firstName: z.string({
33 | required_error: "Imię jest wymagane",
34 | invalid_type_error: "Nieprawidłowy typ danych",
35 | }),
36 | lastName: z.string({
37 | required_error: "Nazwisko jest wymagane",
38 | invalid_type_error: "Nieprawidłowy typ danych",
39 | }),
40 | // TODO: Consider adding a regex to further validate the phone number
41 | phone: z.string({
42 | required_error: "Numer telefonu jest wymagany",
43 | invalid_type_error: "Nieprawidłowy typ danych",
44 | }),
45 | message: z
46 | .string({
47 | required_error: "Wiadomość jest wymagana",
48 | invalid_type_error: "Nieprawidłowy format danych",
49 | })
50 | .max(10240, {
51 | message: "Wiadomość nie może mieć więcej niż 10240 znaków",
52 | }),
53 | })
54 |
55 | export type EmailVerificationFormInput = z.infer
56 |
57 | export type MarkEmailAsVerifiedInput = z.infer
58 |
59 | export type CheckIfEmailVerifiedInput = z.infer<
60 | typeof checkIfEmailVerifiedSchema
61 | >
62 |
63 | export type ContactFormInput = z.infer
64 |
--------------------------------------------------------------------------------
/src/components/sections/team-section.tsx:
--------------------------------------------------------------------------------
1 | export function TeamSection(): JSX.Element {
2 | return (
3 |
7 |
8 |
13 |
14 |
15 |
19 |
20 |
21 |
22 | Wykwalifikowana
23 |
24 |
25 |
26 | i doświadczona kadra
27 |
28 |
29 |
30 |
31 |
32 |
33 | {/*
*/}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
45 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/emails/auth/magic-link-email.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Button,
4 | Container,
5 | Head,
6 | Html,
7 | Preview,
8 | Section,
9 | Tailwind,
10 | Text,
11 | } from "@react-email/components"
12 |
13 | import { siteConfig } from "@/config/site"
14 |
15 | interface MagicLinkEmailProps {
16 | identifier: string
17 | url: string
18 | }
19 |
20 | export function MagicLinkEmail({
21 | identifier,
22 | url,
23 | }: MagicLinkEmailProps): JSX.Element {
24 | const previewText = `${siteConfig.nameShort} magic link sign in.`
25 | return (
26 |
27 |
28 | {previewText}
29 |
30 | {previewText}
31 |
32 |
33 |
34 |
35 | Hi,
36 |
37 | Someone just requested a Sign In magic link for {identifier}
38 |
39 | If this was you, you can sign in here:
40 | Sign in
41 |
42 |
43 |
44 | If you didn't try to login, you can safely ignore this
45 | email.
46 |
47 |
48 |
49 |
50 | Enjoy{" "}
51 |
52 | {siteConfig.nameShort}
53 | {" "}
54 | and have a nice day!
55 |
56 |
57 |
58 |
59 | Hint: You can set a permanent password in Dashboard → Settings
60 |
61 |
62 |
63 |
64 |
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/src/validations/user.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod"
2 |
3 | import { users } from "@/db/schema"
4 | import { passwordSchema, userIdSchema } from "@/validations/auth"
5 | import { emailSchema } from "@/validations/email"
6 |
7 | export const userNameSchema = z
8 | .string({
9 | invalid_type_error: "Imię lub nazwisko muszą być tekstem",
10 | })
11 | .optional()
12 |
13 | export const userSchema = z.object({
14 | name: userNameSchema,
15 | surname: userNameSchema,
16 | role: z
17 | .enum(users.role.enumValues, {
18 | required_error: "Rola jest wymagana",
19 | invalid_type_error:
20 | "Rola musi być jedną z predefiniowanych wartości tekstowych",
21 | })
22 | .default("klient"),
23 | email: emailSchema,
24 | password: passwordSchema.regex(
25 | /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/,
26 | {
27 | message:
28 | "Hasło musi mieć od 8 do 256 znaków, zawierać przynajmniej jedną wielką literę, jedną małą literę, jedną liczbę, oraz jedną znak specjalny",
29 | }
30 | ),
31 | })
32 |
33 | export const getUserByEmailSchema = z.object({
34 | email: emailSchema,
35 | })
36 |
37 | export const getUserByIdSchema = z.object({
38 | id: userIdSchema,
39 | })
40 |
41 | export const getUserByResetPasswordTokenSchema = z.object({
42 | token: z.string(),
43 | })
44 |
45 | export const getUserByEmailVerificationTokenSchema = z.object({
46 | token: z.string(),
47 | })
48 |
49 | export const checkIfUserExistsSchema = z.object({
50 | id: userIdSchema,
51 | })
52 |
53 | export type GetUserByEmailInput = z.infer
54 |
55 | export type GetUserByIdInput = z.infer
56 |
57 | export type GetUserByResetPasswordTokenInput = z.infer<
58 | typeof getUserByResetPasswordTokenSchema
59 | >
60 |
61 | export type GetUserByEmailVerificationTokenInput = z.infer<
62 | typeof getUserByEmailVerificationTokenSchema
63 | >
64 |
65 | export type CheckIfUserExistsInput = z.infer
66 |
--------------------------------------------------------------------------------
/src/components/data-table/data-table-view-options.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"
4 | import { MixerHorizontalIcon } from "@radix-ui/react-icons"
5 | import { type Table } from "@tanstack/react-table"
6 |
7 | import { toSentenceCase } from "@/lib/utils"
8 |
9 | import { Button } from "@/components/ui/button"
10 | import {
11 | DropdownMenu,
12 | DropdownMenuCheckboxItem,
13 | DropdownMenuContent,
14 | DropdownMenuLabel,
15 | DropdownMenuSeparator,
16 | } from "@/components/ui/dropdown-menu"
17 |
18 | interface DataTableViewOptionsProps {
19 | table: Table
20 | }
21 |
22 | export function DataTableViewOptions({
23 | table,
24 | }: DataTableViewOptionsProps): JSX.Element {
25 | return (
26 |
27 |
28 |
34 |
35 | Widok
36 |
37 |
38 |
39 | Wybierz kolumny
40 |
41 | {table
42 | .getAllColumns()
43 | .filter(
44 | (column) =>
45 | typeof column.accessorFn !== "undefined" && column.getCanHide()
46 | )
47 | .map((column) => {
48 | return (
49 | column.toggleVisibility(!!value)}
54 | >
55 | {toSentenceCase(column.id)}
56 |
57 | )
58 | })}
59 |
60 |
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/emails/auth/email-verification-email.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Button,
4 | Container,
5 | Head,
6 | Html,
7 | Preview,
8 | Section,
9 | Tailwind,
10 | Text,
11 | } from "@react-email/components"
12 |
13 | import { siteConfig } from "@/config/site"
14 |
15 | interface EmailVerificationEmailProps {
16 | email: string
17 | emailVerificationToken: string
18 | }
19 |
20 | export function EmailVerificationEmail({
21 | email,
22 | emailVerificationToken,
23 | }: Readonly): JSX.Element {
24 | const previewText = `Weryfikacja maila na stronie ${siteConfig.nameShort}`
25 | return (
26 |
27 |
28 | {previewText}
29 |
30 | {previewText}
31 |
32 |
33 |
34 |
35 | Heja,
36 |
37 | Ktoś spróbował właśnie użyć adresu {email} w celu założenia
38 | konta administratora na stronie{" "}
39 |
40 | {siteConfig.nameShort}
41 |
42 | .
43 |
44 |
45 | Jeżeli to byłeś Ty, kliknij w poniższy link aby potwierdzić swój
46 | adres email i dokończyć proces zakładanie konta.
47 |
48 |
51 | Potwierdź adres email
52 |
53 |
54 |
55 |
56 |
57 | Jeżeli to nie Ty próbowałeś się zarejestrować, zignoruj tego
58 | maila.
59 |
60 | Miłego dnia!
61 |
62 |
63 |
64 |
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { addMinutes, format } from "date-fns"
3 | import dayjs from "dayjs"
4 | import { customAlphabet } from "nanoid"
5 | import { twMerge } from "tailwind-merge"
6 |
7 | export function cn(...inputs: ClassValue[]): string {
8 | return twMerge(clsx(inputs))
9 | }
10 |
11 | export function formatDate(date: Date | string): string {
12 | return dayjs(date).format("MMMM D, YYYY")
13 | }
14 |
15 | export function formatTime(date: Date | string) {
16 | return dayjs(date).format("HH:mm")
17 | }
18 |
19 | export function isMacOs(): boolean {
20 | return window.navigator.userAgent.includes("Mac")
21 | }
22 |
23 | export function scrollToSection(sectionName: string): void {
24 | document
25 | ?.getElementById(`${sectionName}`)
26 | ?.scrollIntoView({ behavior: "smooth" })
27 | }
28 |
29 | export function generateId(length = 128) {
30 | return customAlphabet(
31 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
32 | length
33 | )()
34 | }
35 |
36 | export function slugify(str: string): string {
37 | return str
38 | .toLowerCase()
39 | .replace(/ /g, "-")
40 | .replace(/[^\w-]+/g, "")
41 | .replace(/--+/g, "-")
42 | }
43 |
44 | export function toSentenceCase(str: string): string {
45 | return str
46 | .replace(/([A-Z])/g, " $1")
47 | .replace(/^./, (str) => str.toUpperCase())
48 | }
49 |
50 | export function generateTimeOptions(interval: number): string[] | null {
51 | if (Number.isInteger(interval) && interval >= 0 && interval <= 60) {
52 | const timeList: string[] = []
53 | let currentTime: Date = new Date(0, 0, 0, 0, 0, 0)
54 |
55 | while (currentTime <= new Date(0, 0, 0, 23, 59, 59)) {
56 | timeList.push(format(currentTime, "HH:mm"))
57 | currentTime = addMinutes(currentTime, interval)
58 | }
59 |
60 | return timeList
61 | } else {
62 | console.error(
63 | "Invalid interval. Please provide a positive integer between 0 and 60."
64 | )
65 | return null
66 | }
67 | }
68 |
69 | export function absoluteUrl(path: string): string {
70 | return `${process.env.NEXT_PUBLIC_APP_URL}${path}`
71 | }
72 |
73 | export function handleClickSecondaryButton() {}
74 |
--------------------------------------------------------------------------------
/src/components/page-header.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from "class-variance-authority"
2 | import { Balancer } from "react-wrap-balancer"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | interface PageHeaderProps extends React.HTMLAttributes {
7 | as?: React.ElementType
8 | }
9 |
10 | function PageHeader({
11 | className,
12 | children,
13 | as: Comp = "section",
14 | ...props
15 | }: PageHeaderProps) {
16 | return (
17 |
18 | {children}
19 |
20 | )
21 | }
22 |
23 | const headingVariants = cva(
24 | "font-bold leading-tight tracking-tighter lg:leading-[1.1]",
25 | {
26 | variants: {
27 | size: {
28 | default: "text-3xl md:text-4xl",
29 | sm: "text-2xl md:text-3xl",
30 | lg: "text-4xl md:text-5xl",
31 | },
32 | },
33 | defaultVariants: {
34 | size: "default",
35 | },
36 | }
37 | )
38 |
39 | interface PageHeaderHeadingProps
40 | extends React.HTMLAttributes,
41 | VariantProps {
42 | as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
43 | }
44 |
45 | function PageHeaderHeading({
46 | className,
47 | size,
48 | as: Comp = "h1",
49 | ...props
50 | }: PageHeaderHeadingProps) {
51 | return (
52 |
53 | )
54 | }
55 |
56 | const descriptionVariants = cva("max-w-[750px] text-muted-foreground", {
57 | variants: {
58 | size: {
59 | default: "text-base sm:text-lg",
60 | sm: "text-sm sm:text-base",
61 | lg: "text-lg sm:text-xl",
62 | },
63 | },
64 | defaultVariants: {
65 | size: "default",
66 | },
67 | })
68 |
69 | interface PageHeaderDescriptionProps
70 | extends React.ComponentProps,
71 | VariantProps {}
72 |
73 | function PageHeaderDescription({
74 | className,
75 | size,
76 | ...props
77 | }: PageHeaderDescriptionProps) {
78 | return (
79 |
84 | )
85 | }
86 |
87 | export { PageHeader, PageHeaderDescription, PageHeaderHeading }
88 |
--------------------------------------------------------------------------------
/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | import { Icons } from "@/components/icons"
9 |
10 | const Accordion = AccordionPrimitive.Root
11 |
12 | const AccordionItem = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | AccordionItem.displayName = "AccordionItem"
23 |
24 | const AccordionTrigger = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, children, ...props }, ref) => (
28 |
29 | svg]:rotate-180",
33 | className
34 | )}
35 | {...props}
36 | >
37 | {children}
38 |
39 |
40 |
41 | ))
42 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
43 |
44 | const AccordionContent = React.forwardRef<
45 | React.ElementRef,
46 | React.ComponentPropsWithoutRef
47 | >(({ className, children, ...props }, ref) => (
48 |
56 | {children}
57 |
58 | ))
59 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
60 |
61 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
62 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/src/components/nav/admin/clinic-tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useRouter, useSelectedLayoutSegment } from "next/navigation"
4 | import { Tabs, TabsList, TabsTrigger } from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | import { Separator } from "@/components/ui/separator"
9 |
10 | export function ClinicTabs() {
11 | const router = useRouter()
12 | const segment = useSelectedLayoutSegment()
13 |
14 | const tabs = [
15 | {
16 | title: "Dane",
17 | href: `/admin/przychodnia/`,
18 | isActive: segment === null,
19 | },
20 | {
21 | title: "Godziny przyjęć",
22 | href: `/admin/przychodnia/godziny`,
23 | isActive: segment === "godziny",
24 | },
25 | {
26 | title: "Dni wolne",
27 | href: `/admin/przychodnia/dni-wolne`,
28 | isActive: segment === "dni-wolne",
29 | },
30 | {
31 | title: "Rezerwacje",
32 | href: `/admin/przychodnia/rezerwacje`,
33 | isActive: segment === "rezerwacje",
34 | },
35 | ]
36 |
37 | return (
38 | tab.isActive)?.href ?? tabs[0]?.href}
40 | className="sticky top-0 z-30 w-full overflow-auto bg-background px-1"
41 | onValueChange={(value) => router.push(value)}
42 | >
43 |
44 | {tabs.map((tab) => (
45 |
53 |
60 | {tab.title}
61 |
62 |
63 | ))}
64 |
65 |
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/error-card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import Link from "next/link"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | import { Button, buttonVariants } from "@/components/ui/button"
7 | import {
8 | Card,
9 | CardContent,
10 | CardDescription,
11 | CardFooter,
12 | CardHeader,
13 | CardTitle,
14 | } from "@/components/ui/card"
15 | import { Icons } from "@/components/icons"
16 |
17 | interface ErrorCardProps extends React.ComponentPropsWithoutRef {
18 | icon?: keyof typeof Icons
19 | title: string
20 | description: string
21 | retryLink?: string
22 | retryLinkText?: string
23 | reset?: () => void
24 | }
25 |
26 | export function ErrorCard({
27 | icon,
28 | title,
29 | description,
30 | retryLink,
31 | retryLinkText = "Spróbuj ponownie",
32 | reset,
33 | className,
34 | ...props
35 | }: ErrorCardProps): JSX.Element {
36 | const Icon = Icons[icon ?? "warning"]
37 |
38 | return (
39 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | {title}
53 |
54 | {description}
55 |
56 |
57 | {retryLink ? (
58 |
59 |
67 | {retryLinkText}
68 | {retryLinkText}
69 |
70 |
71 | ) : null}
72 | {reset ? (
73 |
74 |
75 | Spróbuj ponownie
76 |
77 |
78 | ) : null}
79 |
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/src/components/emails/auth/reset-password-email.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Button,
4 | Container,
5 | Head,
6 | Html,
7 | Preview,
8 | Section,
9 | Tailwind,
10 | Text,
11 | } from "@react-email/components"
12 |
13 | import { siteConfig } from "@/config/site"
14 |
15 | import { absoluteUrl } from "@/lib/utils"
16 |
17 | interface ResetPasswordEmailProps {
18 | email: string
19 | resetPasswordToken: string
20 | }
21 |
22 | export function ResetPasswordEmail({
23 | email,
24 | resetPasswordToken,
25 | }: Readonly): JSX.Element {
26 | const previewText = `${siteConfig.nameShort} password reset.`
27 |
28 | return (
29 |
30 |
31 | {previewText}
32 |
33 | {previewText}
34 |
35 |
36 |
37 |
38 | Hi,
39 |
40 | Someone just requested a password change for your{" "}
41 | {siteConfig.nameShort}
42 | account associated with {email}.
43 |
44 |
45 | If this was you, you can set a new password here:
46 |
47 |
52 | Set new password
53 |
54 |
55 |
56 |
57 | If you don't want to change your password or didn't
58 | request this, just ignore and delete this message.
59 |
60 |
61 | To keep your account secure, please don't forward this
62 | email to anyone.
63 |
64 |
65 |
66 |
67 | Enjoy{" "}
68 |
69 | {siteConfig.nameShort}
70 | {" "}
71 | and have a nice day!
72 |
73 |
74 |
75 |
76 |
77 |
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/sections/about-section.tsx:
--------------------------------------------------------------------------------
1 | import Balancer from "react-wrap-balancer"
2 |
3 | import { aboutSectionParagraphs } from "@/data/promo-text"
4 |
5 | export function AboutSection(): JSX.Element {
6 | return (
7 |
8 |
9 |
10 |
15 |
16 |
17 |
18 |
19 |
20 | Pasja i wiedza,{" "}
21 |
22 |
23 |
24 | przekazywane od pokoleń
25 |
26 |
27 |
28 |
29 | {aboutSectionParagraphs?.map((paragraph, index) => (
30 |
34 | {paragraph.content}
35 |
36 | ))}
37 |
38 |
39 | Zapraszamy!
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/app/(admin)/admin/dostepnosc/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next"
2 | import { redirect } from "next/navigation"
3 | import { getBusinessHours } from "@/actions/availability"
4 |
5 | import { DEFAULT_UNAUTHENTICATED_REDIRECT } from "@/config/defaults"
6 |
7 | import auth from "@/lib/auth"
8 |
9 | import {
10 | Card,
11 | CardContent,
12 | CardDescription,
13 | CardHeader,
14 | CardTitle,
15 | } from "@/components/ui/card"
16 | import { BusinessHoursUpdateForm } from "@/components/forms/clinic/business-hours-update-form"
17 | import {
18 | PageHeader,
19 | PageHeaderDescription,
20 | PageHeaderHeading,
21 | } from "@/components/page-header"
22 | import { Shell } from "@/components/shells/shell"
23 |
24 | export const metadata: Metadata = {
25 | metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL),
26 | title: "Dostępność",
27 | description: "Określaj dni i godziny przyjęć",
28 | }
29 |
30 | export default async function AvailabilityPage(): Promise {
31 | const session = await auth()
32 | if (!session) redirect(DEFAULT_UNAUTHENTICATED_REDIRECT)
33 |
34 | const currentBusinessHours = await getBusinessHours()
35 |
36 | return (
37 |
38 |
39 | Dostępność
40 |
41 | Zarządzanie dostępnością
42 |
43 |
44 |
45 | {/* Opening hours */}
46 |
47 |
48 | Godziny przyjęć
49 |
50 | Godziny, w których przyjmujesz klientów
51 |
52 |
53 |
54 |
57 |
58 |
59 |
60 | {/* Days unavailable */}
61 |
62 |
63 | Dni wolne
64 |
65 | Dni, w których nie przyjmujesz klientów
66 |
67 |
68 | kalendarz i możliwość dodania dni wolnych
69 |
70 |
71 |
72 | )
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/sections/services-section.tsx:
--------------------------------------------------------------------------------
1 | import Balancer from "react-wrap-balancer"
2 |
3 | import { servicesSectionParagraphs } from "@/data/promo-text"
4 | import { services } from "@/data/services"
5 |
6 | import { ServiceCard } from "@/components/service-card"
7 |
8 | export function ServicesSection(): JSX.Element {
9 | return (
10 |
14 |
15 |
16 |
17 |
18 |
19 | Bogata oferta
20 |
21 |
22 |
23 | i szeroki zakres usług
24 |
25 |
26 |
27 | {servicesSectionParagraphs?.map((paragraph, index) => (
28 |
32 | {paragraph.content}
33 |
34 | ))}
35 |
36 |
37 |
38 |
43 |
44 |
45 |
46 |
47 | {services.map((service, index) => (
48 |
49 | ))}
50 |
51 |
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/src/app/api/og/route.tsx:
--------------------------------------------------------------------------------
1 | import { ImageResponse } from "next/og"
2 |
3 | import { ogImageSchema } from "@/validations/og"
4 |
5 | export const runtime = "edge"
6 |
7 | export function GET(req: Request) {
8 | try {
9 | const url = new URL(req.url)
10 | const parsedValues = ogImageSchema.parse(
11 | Object.fromEntries(url.searchParams)
12 | )
13 |
14 | const { mode, title, description, type } = parsedValues
15 | const paint = mode === "dark" ? "#fff" : "#000"
16 |
17 | return new ImageResponse(
18 | (
19 |
29 |
40 |
41 |
42 |
43 |
44 |
45 |
51 | {type ? (
52 |
53 | {type}
54 |
55 | ) : null}
56 |
57 | {title}
58 |
59 | {description ? (
60 |
61 | {description}
62 |
63 | ) : null}
64 |
65 |
66 | ),
67 | {
68 | width: 1200,
69 | height: 630,
70 | }
71 | )
72 | } catch (error) {
73 | error instanceof Error
74 | ? console.log(`${error.message}`)
75 | : console.log(error)
76 | return new Response(`Failed to generate the image`, {
77 | status: 500,
78 | })
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { DayPicker } from "react-day-picker"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | import { buttonVariants } from "@/components/ui/button"
9 | import { Icons } from "@/components/icons"
10 |
11 | export type CalendarProps = React.ComponentProps
12 |
13 | function Calendar({
14 | className,
15 | classNames,
16 | showOutsideDays = true,
17 | ...props
18 | }: CalendarProps) {
19 | return (
20 | (
57 |
58 | ),
59 | IconRight: ({ ...props }) => (
60 |
61 | ),
62 | }}
63 | {...props}
64 | />
65 | )
66 | }
67 | Calendar.displayName = "Calendar"
68 |
69 | export { Calendar }
70 |
--------------------------------------------------------------------------------
/src/data/services.ts:
--------------------------------------------------------------------------------
1 | export const services = [
2 | {
3 | title: "Chirurgia",
4 | description: "",
5 | bulletPoints: [
6 | "Sterylizacje, kastracje, cięcia cesarskie",
7 | "Leczenie przypadków pourazowych",
8 | "Chirurgia miękka narządów wewnętrznych, kostna i onkologiczna",
9 | "Plastyczne opracowywanie ran i wad wrodzonych (korekcje powiek, warg, zewnętrznych narządów płciowych)",
10 | "Po operacji zwierzaki pozostają w szpitalu do momentu wybudzenia z narkozy",
11 | ],
12 | bulletPointsRest: [],
13 | },
14 | {
15 | title: "Diagnostyka",
16 | description: "",
17 | bulletPoints: [
18 | "Badania morfologiczne i badania biochemiczne krwi",
19 | "Badania moczu, kału i zeskrobin",
20 | "Badania RTG",
21 | "Badania USG",
22 | "Videootoskopia kanału słuchowego",
23 | "Współpracujemy z laboratoriami zewnętrznymi",
24 | ],
25 | bulletPointsRest: [],
26 | },
27 | {
28 | title: "Salon groomerski",
29 | description: "",
30 | bulletPoints: [
31 | "Strzyżenie według wzorca rasy",
32 | "Strzyżenie zgodnie z życzeniem klienta",
33 | "Kąpiele pielęgnacyjne",
34 | "Kąpiele lecznicze",
35 | ],
36 | },
37 | {
38 | title: "Szpital",
39 | description: "",
40 | bulletPoints: [
41 | "Stała opieka lekarza",
42 | "Intensywna terapia",
43 | "Możliwość hospitalizacji",
44 | ],
45 | },
46 | {
47 | title: "Cyfrowa baza danych",
48 | description: "",
49 | bulletPoints: [
50 | "Możliwość umówienia wizyty na konkretną datę i godzinę",
51 | "Wszystkie informacje są rejestrowane i przechowywane w nowoczesnej bazie danych przy użyciu programu Klinika XP",
52 | "Możliwość uzyskania wydruku z pełnym zapisem dotychczasowego przebiegu leczenia",
53 | ],
54 | },
55 | {
56 | id: 6,
57 | title: "Profilaktyka",
58 | description: "",
59 | bulletPoints: [
60 | "Szczepienia przeciwko chorobom zakaźnym psów, kotów, królików oraz fretek (nie wywołują reakcji alergicznych)",
61 | "Szczepienia przeciwko wściekliźnie",
62 | "Szczepienia przeciwko boreliozie",
63 | "Zabezpieczanie przeciwko pasożytom zewnętrznym",
64 | ],
65 | },
66 | {
67 | id: 7,
68 | title: "Czipowanie",
69 | description: "",
70 | bulletPoints: [
71 | "Elektroniczne znakowanie zwierząt (bezbolesne wszczepianie mikroprocesora pod skórę) wykonywane jest u psów, kotów i fretek.",
72 | ],
73 | },
74 | {
75 | title: "Paszporty",
76 | description: "",
77 | bulletPoints: [
78 | "Paszporty unijne, przygotowywane do wyjazdów zagranicznych z uwzględnieniem specyficznych przepisów niektórych państw",
79 | ],
80 | },
81 | {
82 | title: "Dietetyka",
83 | description:
84 | "W naszej przychodni możesz skorzystać z konsultacji dietetycznej dla swojego pupila.",
85 | },
86 | ]
87 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @font-face {
6 | font-family: "Baloo";
7 | src: url("/fonts/baloo-regular.woff2");
8 | }
9 |
10 | @font-face {
11 | font-family: "BalooTamma";
12 | src: url("/fonts/baloo-tamma.woff2");
13 | }
14 |
15 | @layer base {
16 | :root {
17 | --background: 0 0% 100%;
18 | --foreground: 240 10% 3.9%;
19 | --card: 0 0% 100%;
20 | --card-foreground: 240 10% 3.9%;
21 | --popover: 0 0% 100%;
22 | --popover-foreground: 240 10% 3.9%;
23 | --primary: 240 5.9% 10%;
24 | --primary-foreground: 0 0% 98%;
25 | --secondary: 240 4.8% 95.9%;
26 | --secondary-foreground: 240 5.9% 10%;
27 | --muted: 240 4.8% 95.9%;
28 | --muted-foreground: 240 3.8% 46.1%;
29 | --accent: 240 4.8% 95.9%;
30 | --accent-foreground: 240 5.9% 10%;
31 | --destructive: 0 84.2% 60.2%;
32 | --destructive-foreground: 0 0% 98%;
33 | --border: 240 5.9% 90%;
34 | --input: 240 5.9% 90%;
35 | --ring: 240 5.9% 10%;
36 |
37 | --peach: #f9f0e7;
38 | --peach-services-text: #ffe1cc;
39 | --green-gradient-from: #2ba0ab;
40 | --green-gradient-to: #124b4b;
41 | --white-gradient-from: #fff;
42 | --white-gradient-to: #e9fafc;
43 | --primary-green: #2a3342;
44 | --secondary-green: #175a5c;
45 | --green-navbar-text: #2b6969;
46 | --green-services-text: #89d3d2;
47 | --green-navbar-hover: #207b81;
48 | --green-button-background: #228383;
49 | --green-button-text: #fef1eb;
50 | --green-contact-form-text: #89d3d2;
51 | --location-text: #170e09;
52 | --primary-button-border: #f3decc;
53 | --secondary-button-border: #b9f1dd;
54 | --appointment-button-border: #f6e0ce;
55 | --contact-button-border: #fef1ec;
56 | --contact-button-background: #95eece;
57 | --off-white-text: #fcf1eb;
58 | --light-section-text: #278c96;
59 | --dark-section-text: #dae7e9;
60 |
61 | --radius: 0.5rem;
62 | }
63 | }
64 |
65 | @layer base {
66 | * {
67 | @apply border-border;
68 | }
69 | body {
70 | @apply bg-background text-foreground;
71 | }
72 |
73 | input[type="password"]::-ms-reveal,
74 | input[type="password"]::-ms-clear,
75 | input[type="password"]::-webkit-password-toggle-button {
76 | display: none !important;
77 | }
78 | }
79 |
80 | @layer components {
81 | .zoom-image [data-rmiz-modal-overlay="visible"] {
82 | @apply bg-background/10 backdrop-blur;
83 | }
84 |
85 | .zoom-image [data-rmiz-modal-img] {
86 | @apply rounded-md lg:rounded-2xl;
87 | }
88 |
89 | .location-pill {
90 | background: linear-gradient(85.38deg, #f5f5f5 10.45%, #95eece 128.37%);
91 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.1);
92 | border-radius: 16px;
93 | }
94 | }
95 |
96 | @media (max-width: 640px) {
97 | .container {
98 | @apply px-4;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/app/(auth)/logowanie/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next"
2 | import Link from "next/link"
3 | import { redirect } from "next/navigation"
4 |
5 | import { DEFAULT_SIGNIN_REDIRECT } from "@/config/defaults"
6 |
7 | import auth from "@/lib/auth"
8 |
9 | import {
10 | Card,
11 | CardContent,
12 | CardDescription,
13 | CardFooter,
14 | CardHeader,
15 | CardTitle,
16 | } from "@/components/ui/card"
17 | import { SignInWithPasswordForm } from "@/components/forms/auth/signin-with-password-form"
18 | import { Icons } from "@/components/icons"
19 |
20 | export const metadata: Metadata = {
21 | metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL),
22 | title: "Logowanie",
23 | description: "Zaloguj się by zarządzać rezerwacjami i danymi przychodni",
24 | }
25 |
26 | export default async function SignInPage(): Promise {
27 | const session = await auth()
28 | if (session?.user) redirect(DEFAULT_SIGNIN_REDIRECT)
29 |
30 | return (
31 |
32 |
33 |
34 |
35 | Logowanie
36 |
37 |
38 |
39 |
40 |
41 | Zaloguj się by zarządzać rezerwacjami
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | Nie posiadasz konta?
51 |
56 | Załóż konto
57 | Załóż konto
58 |
59 | .
60 |
61 |
62 | Zapomniałeś hasła?
63 |
68 | Zresetuj hasło
69 | Zresetuj hasło
70 |
71 | .
72 |
73 |
74 |
75 |
76 | )
77 | }
78 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "@/styles/globals.css"
2 |
3 | import * as React from "react"
4 | import type { Metadata, Viewport } from "next"
5 | import { Analytics } from "@vercel/analytics/react"
6 |
7 | import { fontInter, fontJetBrainsMono } from "@/config/fonts"
8 | import { siteConfig } from "@/config/site"
9 |
10 | import { SmoothScrollProvider } from "@/providers/smooth-scroll-provider"
11 | import { ThemeProvider } from "@/providers/theme-provider"
12 | import { cn } from "@/lib/utils"
13 |
14 | import { Toaster } from "@/components/ui/toaster"
15 | import { TailwindIndicator } from "@/components/tailwind-indicator"
16 |
17 | export const viewport: Viewport = {
18 | width: "device-width",
19 | initialScale: 1,
20 | minimumScale: 1,
21 | maximumScale: 1,
22 | themeColor: [
23 | { media: "(prefers-color-scheme: light)", color: "white" },
24 | { media: "(prefers-color-scheme: dark)", color: "black" },
25 | ],
26 | }
27 |
28 | export const metadata: Metadata = {
29 | metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL),
30 | title: {
31 | default: siteConfig.nameLong,
32 | template: `%s - ${siteConfig.nameLong}`,
33 | },
34 | description: siteConfig.description,
35 | keywords: siteConfig.keywords,
36 | authors: [
37 | {
38 | name: "Piotr Borowiecki",
39 | url: "https://pjborowiecki.com",
40 | },
41 | ],
42 | creator: "@pjborowiecki",
43 | robots: {
44 | index: true,
45 | follow: true,
46 | },
47 | openGraph: {
48 | type: "website",
49 | locale: "en_US",
50 | url: siteConfig.url,
51 | title: siteConfig.nameLong,
52 | description: siteConfig.description,
53 | siteName: siteConfig.nameLong,
54 | },
55 | twitter: {
56 | card: "summary_large_image",
57 | title: siteConfig.nameLong,
58 | description: siteConfig.description,
59 | images: [siteConfig.ogImage],
60 | creator: siteConfig.author,
61 | },
62 | icons: {
63 | icon: "/favicon.ico",
64 | },
65 | }
66 |
67 | interface RootLayoutProps {
68 | children: React.ReactNode
69 | }
70 |
71 | export default function RootLayout({
72 | children,
73 | }: Readonly): JSX.Element {
74 | return (
75 |
76 |
83 |
84 |
90 | {children}
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | )
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/nav/admin/navigation-mobile.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import Link from "next/link"
5 | import { useSelectedLayoutSegment } from "next/navigation"
6 | import type { NavItem } from "@/types"
7 |
8 | import { siteConfig } from "@/config/site"
9 |
10 | import { cn } from "@/lib/utils"
11 |
12 | import { Button } from "@/components/ui/button"
13 | import { ScrollArea } from "@/components/ui/scroll-area"
14 | import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
15 | import { Icons } from "@/components/icons"
16 |
17 | interface NavigationMobileProps {
18 | items?: NavItem[]
19 | }
20 |
21 | interface MobileLinkProps extends React.PropsWithChildren {
22 | href: string
23 | disabled?: boolean
24 | segment: string
25 | setIsOpen: React.Dispatch>
26 | }
27 |
28 | export function MobileLink({
29 | children,
30 | href,
31 | disabled,
32 | segment,
33 | setIsOpen,
34 | }: MobileLinkProps): JSX.Element {
35 | return (
36 | setIsOpen(false)}
44 | >
45 | {children}
46 |
47 | )
48 | }
49 |
50 | export function NavigationMobile({
51 | items,
52 | }: NavigationMobileProps): JSX.Element {
53 | const segment = useSelectedLayoutSegment()
54 | const [isOpen, setIsOpen] = React.useState(false)
55 |
56 | return (
57 |
58 |
59 |
63 |
64 | Toggle Menu
65 |
66 |
67 |
68 |
69 | setIsOpen(false)}
74 | >
75 | {siteConfig.nameShort}
76 |
77 |
78 |
79 |
80 | {items?.map((item, index) => (
81 |
88 | {item.title}
89 |
90 | ))}
91 |
92 |
93 |
94 |
95 | )
96 | }
97 |
--------------------------------------------------------------------------------
/src/app/(auth)/rejestracja/page.tsx:
--------------------------------------------------------------------------------
1 | import { type Metadata } from "next"
2 | import Link from "next/link"
3 | import { redirect } from "next/navigation"
4 |
5 | import { DEFAULT_SIGNIN_REDIRECT } from "@/config/defaults"
6 |
7 | import auth from "@/lib/auth"
8 |
9 | import {
10 | Card,
11 | CardContent,
12 | CardDescription,
13 | CardFooter,
14 | CardHeader,
15 | CardTitle,
16 | } from "@/components/ui/card"
17 | import { SignUpWithPasswordForm } from "@/components/forms/auth/signup-with-password-form"
18 | import { Icons } from "@/components/icons"
19 |
20 | export const metadata: Metadata = {
21 | metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL),
22 | title: "Rejestracja",
23 | description: "Załóż konto aby korzystać z panelu administratora",
24 | }
25 |
26 | export default async function SignUpPage(): Promise {
27 | const session = await auth()
28 | if (session?.user) redirect(DEFAULT_SIGNIN_REDIRECT)
29 |
30 | return (
31 |
32 |
33 |
34 |
35 | Rejestracja
36 |
37 |
38 |
39 |
40 |
41 | Załóż konto by uzyskać władzę administratora
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | Posiadasz już konto?
51 |
56 | Zaloguj się
57 | Zaloguj się
58 |
59 | .
60 |
61 |
62 | Zgubiłeś link weryfikacyjny?
63 |
68 | Wyślij ponownie
69 |
70 | Wyślij link weryfikacyjny ponownie
71 |
72 |
73 | .
74 |
75 |
76 |
77 |
78 |
79 | )
80 | }
81 |
--------------------------------------------------------------------------------
/src/lib/booking.ts:
--------------------------------------------------------------------------------
1 | import { type Booking, type BusinessHours } from "@/db/schema"
2 |
3 | export function getDaysClosed(
4 | businessHours: BusinessHours,
5 | daysOfWeek: string[]
6 | ): number[] {
7 | try {
8 | const daysClosed: number[] = []
9 |
10 | for (const day of daysOfWeek) {
11 | const status = businessHours[`${day}Status` as keyof BusinessHours]
12 | if (status === "zamknięte") {
13 | daysClosed.push(daysOfWeek.indexOf(day))
14 | }
15 | }
16 |
17 | return daysClosed
18 | } catch (error) {
19 | console.error(error)
20 | throw new Error("Błąd przy wczytywaniu dni wolnych od pracy")
21 | }
22 | }
23 |
24 | export function getTimeOptions(
25 | selectedDate: Date,
26 | bookingType: string,
27 | existingBookings: Booking[],
28 | businessHours: BusinessHours,
29 | timeInterval: number
30 | ): string[] {
31 | try {
32 | const nowLocally = new Date()
33 |
34 | const selectedDay =
35 | selectedDate &&
36 | selectedDate
37 | .toLocaleDateString("en-US", { weekday: "long" })
38 | .toLowerCase()
39 |
40 | const openingTime =
41 | businessHours[`${selectedDay}Opening` as keyof BusinessHours]
42 |
43 | const closingTime =
44 | businessHours[`${selectedDay}Closing` as keyof BusinessHours]
45 |
46 | const currentTime = `${String(nowLocally.getHours()).padStart(
47 | 2,
48 | "0"
49 | )}:${String(nowLocally.getMinutes()).padStart(2, "0")}`
50 |
51 | if (
52 | openingTime &&
53 | closingTime &&
54 | currentTime &&
55 | typeof openingTime === "string" &&
56 | typeof closingTime === "string" &&
57 | typeof currentTime === "string"
58 | ) {
59 | const timeOptions: string[] = []
60 |
61 | const filteredBookings =
62 | existingBookings
63 | ?.filter((booking) => booking.date)
64 | ?.filter((booking) => {
65 | const bookingDate = new Date(booking.date)
66 | return (
67 | booking.type === bookingType &&
68 | bookingDate.toDateString() === selectedDate.toDateString()
69 | )
70 | }) || []
71 |
72 | const currentSlot = new Date(selectedDate)
73 | currentSlot.setHours(Number(openingTime.split(":")[0]))
74 | currentSlot.setMinutes(Number(openingTime.split(":")[1]))
75 |
76 | while (currentSlot.getHours() < Number(closingTime.split(":")[0])) {
77 | const currentSlotString = `${String(currentSlot.getHours()).padStart(
78 | 2,
79 | "0"
80 | )}:${String(currentSlot.getMinutes()).padStart(2, "0")}`
81 |
82 | const enoughAhead =
83 | currentSlot.getTime() > Date.now() + 2 * 60 * 60 * 1000
84 | const bookedAlready = filteredBookings.some(
85 | (booking) => booking.time === currentSlotString
86 | )
87 |
88 | if (enoughAhead && !bookedAlready) {
89 | timeOptions.push(currentSlotString)
90 | }
91 |
92 | currentSlot.setMinutes(currentSlot.getMinutes() + timeInterval)
93 | }
94 |
95 | return timeOptions
96 | }
97 |
98 | return []
99 | } catch (error) {
100 | console.error(error)
101 | return []
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ))
17 | Table.displayName = "Table"
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ))
25 | TableHeader.displayName = "TableHeader"
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ))
37 | TableBody.displayName = "TableBody"
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 |
48 | ))
49 | TableFooter.displayName = "TableFooter"
50 |
51 | const TableRow = React.forwardRef<
52 | HTMLTableRowElement,
53 | React.HTMLAttributes
54 | >(({ className, ...props }, ref) => (
55 |
63 | ))
64 | TableRow.displayName = "TableRow"
65 |
66 | const TableHead = React.forwardRef<
67 | HTMLTableCellElement,
68 | React.ThHTMLAttributes
69 | >(({ className, ...props }, ref) => (
70 |
78 | ))
79 | TableHead.displayName = "TableHead"
80 |
81 | const TableCell = React.forwardRef<
82 | HTMLTableCellElement,
83 | React.TdHTMLAttributes
84 | >(({ className, ...props }, ref) => (
85 |
90 | ))
91 | TableCell.displayName = "TableCell"
92 |
93 | const TableCaption = React.forwardRef<
94 | HTMLTableCaptionElement,
95 | React.HTMLAttributes
96 | >(({ className, ...props }, ref) => (
97 |
102 | ))
103 | TableCaption.displayName = "TableCaption"
104 |
105 | export {
106 | Table,
107 | TableHeader,
108 | TableBody,
109 | TableFooter,
110 | TableHead,
111 | TableRow,
112 | TableCell,
113 | TableCaption,
114 | }
115 |
--------------------------------------------------------------------------------
/src/components/data-table/data-table-column-header.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ArrowDownIcon,
3 | ArrowUpIcon,
4 | CaretSortIcon,
5 | EyeNoneIcon,
6 | } from "@radix-ui/react-icons"
7 | import { type Column } from "@tanstack/react-table"
8 |
9 | import { cn } from "@/lib/utils"
10 |
11 | import { Button } from "@/components/ui/button"
12 | import {
13 | DropdownMenu,
14 | DropdownMenuContent,
15 | DropdownMenuItem,
16 | DropdownMenuSeparator,
17 | DropdownMenuTrigger,
18 | } from "@/components/ui/dropdown-menu"
19 |
20 | interface DataTableColumnHeaderProps
21 | extends React.HTMLAttributes {
22 | column: Column
23 | title: string
24 | }
25 |
26 | export function DataTableColumnHeader({
27 | column,
28 | title,
29 | className,
30 | }: DataTableColumnHeaderProps): JSX.Element {
31 | if (!column.getCanSort()) {
32 | return {title}
33 | }
34 |
35 | return (
36 |
37 |
38 |
39 |
51 | {title}
52 | {column.getIsSorted() === "desc" ? (
53 |
54 | ) : column.getIsSorted() === "asc" ? (
55 |
56 | ) : (
57 |
58 | )}
59 |
60 |
61 |
62 | column.toggleSorting(false)}
65 | >
66 |
70 | Rosnąco
71 |
72 | column.toggleSorting(true)}
75 | >
76 |
80 | Malejąco
81 |
82 |
83 | column.toggleVisibility(false)}
86 | >
87 |
91 | Ukryj
92 |
93 |
94 |
95 |
96 | )
97 | }
98 |
--------------------------------------------------------------------------------
/src/validations/booking.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod"
2 |
3 | import { bookings } from "@/db/schema"
4 | import { hourSchema } from "@/validations/availability"
5 |
6 | export const bookingIdSchema = z
7 | .string({
8 | required_error: "Id rezerwacji jest wymagane",
9 | invalid_type_error: "Dane wejściowe muszą być tekstem",
10 | })
11 | .min(1, {
12 | message: "Id musi mieć przynajmniej 1 znak",
13 | })
14 | .max(128, {
15 | message: "Id może mieć maksymalnie 32 znaki",
16 | })
17 |
18 | export const bookingSchema = z.object({
19 | type: z
20 | .enum(bookings.type.enumValues, {
21 | required_error: "Wybierz rodzaj wizyty",
22 | invalid_type_error: "Nieprawidłowy typ danych",
23 | })
24 | .default(bookings.type.enumValues[0]),
25 | date: z.coerce.date({
26 | required_error: "Wybierz termin wizyty",
27 | invalid_type_error: "Nieprawidłowy typ danych",
28 | }),
29 | time: hourSchema,
30 | firstName: z
31 | .string({
32 | required_error: "Pole jest wymagane",
33 | invalid_type_error: "Nieprawidłowy typ danych",
34 | })
35 | .max(32, {
36 | message: "Imię powinno składać się z maksymalnie 32 znaków",
37 | }),
38 | lastName: z
39 | .string({
40 | required_error: "Pole jest wymagane",
41 | invalid_type_error: "Nieprawidłowy typ danych",
42 | })
43 | .max(32, {
44 | message: "Nazwisko powinno składać się z maksymalnie 32 znaków",
45 | }),
46 | email: z
47 | .string({
48 | required_error: "Pole jest wymagane",
49 | invalid_type_error: "Nieprawidłowy typ danych",
50 | })
51 | .email(),
52 | phone: z
53 | .string({
54 | required_error: "Pole jest wymagane",
55 | invalid_type_error: "Nieprawidłowy typ danych",
56 | })
57 | .min(9, {
58 | message: "Numer telefonu powinien składać się z przynajmniej 9 znaków",
59 | })
60 | .max(20, {
61 | message: "Numer telefonu powinien składać się z maksymalnie 20 znaków",
62 | }),
63 | message: z.string().optional(),
64 | // rodo: z
65 | // .boolean({
66 | // required_error: "Zgoda na przetwarzanie danych jest wymagana",
67 | // invalid_type_error: "Nieprawidłowy typ danych",
68 | // })
69 | // .default(false)
70 | // .refine((value) => value === true, {
71 | // message: "Zgoda na przetwarzanie danych jest wymagana",
72 | // }),
73 | status: z
74 | .enum(bookings.status.enumValues)
75 | .default(bookings.status.enumValues[0]),
76 | })
77 |
78 | export const addBookingSchema = bookingSchema
79 |
80 | export const updateBookingSchema = bookingSchema.extend({
81 | id: bookingIdSchema,
82 | })
83 |
84 | export const deleteBookingSchema = z.object({
85 | id: bookingIdSchema,
86 | })
87 |
88 | export const checkIfBookingExistsSchema = z.object({
89 | id: bookingIdSchema,
90 | })
91 |
92 | export const filterBookingsSchema = z.object({
93 | query: z.string().optional(),
94 | })
95 |
96 | export type AddBookingInput = z.infer
97 |
98 | export type UpdateBookingInput = z.infer
99 |
100 | export type DeleteBookingInput = z.infer
101 |
102 | export type CheckIfBookingExistsInput = z.infer<
103 | typeof checkIfBookingExistsSchema
104 | >
105 |
106 | export type FilterBookingsInput = z.infer
107 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/80",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | landingPrimary:
23 | "border-[3px] border-primaryButtonBorder bg-greenButtonBackground py-[4.5vw] text-[4vw] font-medium tracking-wider text-greenButtonText md:px-[4vw] md:py-[1.3vw] md:text-[1.4vw] md:hover:scale-110 w-1400:px-[56px] w-1400:py-[18px] w-1400:text-[18px]",
24 | landingSecondary:
25 | "gap-[16px] rounded-full border-2 border-secondaryButtonBorder bg-peach py-[4vw] text-[4vw] font-medium tracking-wider text-greenNavbarText md:px-[4vw] md:py-[1.32vw] md:text-[1.4vw] md:hover:scale-110 w-1400:px-[56px] w-1400:py-[18px] w-1400:text-[18px]",
26 | landingAppointment:
27 | "relative z-[2] hidden cursor-pointer rounded-full border-[3px] border-appointmentButtonBorder bg-greenButtonBackground from-greenButtonBackground to-greenNavbarText px-[30px] py-[10px] text-lg font-medium tracking-wide text-offWhiteText shadow-sm hover:scale-110 active:shadow-none md:flex lg:text-base xl:text-[18px]",
28 | landingContact:
29 | "flex items-center justify-center gap-2 border-2 border-contactButtonBorder bg-contactButtonBackground px-6 py-2.5 font-semibold tracking-wider text-greenNavbarText shadow-sm hover:scale-110 hover:shadow-md focus:outline-none disabled:bg-gray-400 disabled:hover:scale-100",
30 | user: "flex items-center justify-center rounded-full",
31 | },
32 | size: {
33 | default: "h-9 px-4 py-2",
34 | sm: "h-8 rounded-md px-3 text-xs",
35 | lg: "h-10 rounded-md px-8",
36 | icon: "size-9",
37 | action: "w-[70vw] rounded-full sm:w-[60vw] md:w-auto",
38 | datePicker: "h-10 px-3 py-2",
39 | contact:
40 | "h-[16vw] w-[50vw] rounded-full border-2 px-6 py-[10px] text-[4.8vw] tracking-wider shadow-sm md:h-[6vw] md:w-[20vw] md:text-[2vw] lg:size-auto lg:text-[16px]",
41 | },
42 | },
43 | defaultVariants: {
44 | variant: "default",
45 | size: "default",
46 | },
47 | }
48 | )
49 |
50 | export interface ButtonProps
51 | extends React.ButtonHTMLAttributes,
52 | VariantProps {
53 | asChild?: boolean
54 | }
55 |
56 | const Button = React.forwardRef(
57 | ({ className, variant, size, asChild = false, ...props }, ref) => {
58 | const Comp = asChild ? Slot : "button"
59 | return (
60 |
65 | )
66 | }
67 | )
68 | Button.displayName = "Button"
69 |
70 | export { Button, buttonVariants }
71 |
--------------------------------------------------------------------------------
/src/actions/user.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { unstable_noStore as noStore } from "next/cache"
4 |
5 | import {
6 | psCheckIfUserExists,
7 | psGetUserByEmail,
8 | psGetUserByEmailVerificationToken,
9 | psGetUserById,
10 | psGetUserByResetPasswordToken,
11 | } from "@/db/prepared-statements/user"
12 | import type { User } from "@/db/schema/index"
13 | import {
14 | checkIfUserExistsSchema,
15 | getUserByEmailSchema,
16 | getUserByEmailVerificationTokenSchema,
17 | getUserByIdSchema,
18 | getUserByResetPasswordTokenSchema,
19 | type CheckIfUserExistsInput,
20 | type GetUserByEmailInput,
21 | type GetUserByEmailVerificationTokenInput,
22 | type GetUserByIdInput,
23 | type GetUserByResetPasswordTokenInput,
24 | } from "@/validations/user"
25 |
26 | export async function getUserById(
27 | rawInput: GetUserByIdInput
28 | ): Promise {
29 | try {
30 | const validatedInput = getUserByIdSchema.safeParse(rawInput)
31 | if (!validatedInput.success) return null
32 |
33 | noStore()
34 | const [user] = await psGetUserById.execute({ id: validatedInput.data.id })
35 | return user || null
36 | } catch (error) {
37 | console.error(error)
38 | throw new Error("Error getting user by id")
39 | }
40 | }
41 |
42 | export async function getUserByEmail(
43 | rawInput: GetUserByEmailInput
44 | ): Promise {
45 | try {
46 | const validatedInput = getUserByEmailSchema.safeParse(rawInput)
47 | if (!validatedInput.success) return null
48 |
49 | noStore()
50 | const [user] = await psGetUserByEmail.execute({
51 | email: validatedInput.data.email,
52 | })
53 | return user || null
54 | } catch (error) {
55 | console.error(error)
56 | throw new Error("Error getting user by email")
57 | }
58 | }
59 |
60 | export async function getUserByResetPasswordToken(
61 | rawInput: GetUserByResetPasswordTokenInput
62 | ): Promise {
63 | try {
64 | const validatedInput = getUserByResetPasswordTokenSchema.safeParse(rawInput)
65 | if (!validatedInput.success) return null
66 |
67 | noStore()
68 | const [user] = await psGetUserByResetPasswordToken.execute({
69 | resetPasswordToken: validatedInput.data.token,
70 | })
71 | return user || null
72 | } catch (error) {
73 | console.error(error)
74 | throw new Error("Error getting user by reset password token")
75 | }
76 | }
77 |
78 | export async function getUserByEmailVerificationToken(
79 | rawInput: GetUserByEmailVerificationTokenInput
80 | ): Promise {
81 | try {
82 | const validatedInput =
83 | getUserByEmailVerificationTokenSchema.safeParse(rawInput)
84 | if (!validatedInput.success) return null
85 |
86 | noStore()
87 | const [user] = await psGetUserByEmailVerificationToken.execute({
88 | emailVerificationToken: validatedInput.data.token,
89 | })
90 | return user || null
91 | } catch (error) {
92 | console.error(error)
93 | throw new Error("Error getting user by email verification token")
94 | }
95 | }
96 |
97 | export async function checkIfUserExists(
98 | rawInput: CheckIfUserExistsInput
99 | ): Promise<"invalid-input" | boolean> {
100 | try {
101 | const validatedInput = checkIfUserExistsSchema.safeParse(rawInput)
102 | if (!validatedInput.success) return "invalid-input"
103 |
104 | noStore()
105 | const exists = await psCheckIfUserExists.execute({
106 | id: validatedInput.data.id,
107 | })
108 |
109 | return exists ? true : false
110 | } catch (error) {
111 | console.error(error)
112 | throw new Error("Error checking if user exists")
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/validations/clinic.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod"
2 |
3 | export const clinicIdSchema = z
4 | .string({
5 | required_error: "Id kliniki jest wymagane",
6 | invalid_type_error: "Dane wejściowe muszą być tekstem",
7 | })
8 | .min(1, {
9 | message: "Id musi mieć przynajmniej 1 znak",
10 | })
11 | .max(128, {
12 | message: "Id może mieć maksymalnie 32 znaki",
13 | })
14 |
15 | export const clinicSchema = z.object({
16 | latitude: z
17 | .string({
18 | required_error: "Szerokość geograficzna jest wymagana",
19 | invalid_type_error: "Szerokość geograficzna musi być tekstem",
20 | })
21 | .max(24, {
22 | message: "Szerokość geograficzna może mieć maksymalnie 24 znaki",
23 | })
24 | .regex(/^(-?[1-8]?\d(\.\d+)?|90(\.0+)?)$/, {
25 | message: "Niepoprawna wartość szerokości geograficznej",
26 | }),
27 | longitude: z
28 | .string({
29 | required_error: "Długość geograficzna jest wymagana",
30 | invalid_type_error: "Długość geograficzna musi być tekstem",
31 | })
32 | .max(24, {
33 | message: "Długość geograficzna może mieć maksymalnie 24 znaki",
34 | })
35 | .regex(/^(0|([1-9]|[1-9]\d|1[0-7]\d)(\.\d+)?)$|^180(\.0+)?$/, {
36 | message: "Niepoprawna wartość długości geograficznej",
37 | }),
38 | address: z
39 | .string({
40 | required_error: "Adres jest wymagany",
41 | invalid_type_error: "Adres musi być tekstem",
42 | })
43 | .min(3, { message: "Adres musi mieć co najmniej 3 znaki" })
44 | .max(128, { message: "Adres może mieć maksymalnie 128 znaków" }),
45 | phone_1: z
46 | .string({
47 | required_error: "Numer telefonu jest wymagany",
48 | invalid_type_error: "Numer telefonu musi być tekstem",
49 | })
50 | .min(8, { message: "Numer telefonu musi mieć co najmniej 8 znaków" })
51 | .max(16, { message: "Numer telefonu może mieć maksymalnie 16 znaków" })
52 | .regex(/^\+?[0-9 ]{9,15}$/, {
53 | message:
54 | "Niepoprawny format numeru telefonu. Dozwolone są tylko cyfry i opcjonalnie + na początku",
55 | }),
56 | phone_2: z
57 | .string({
58 | required_error: "Numer telefonu jest wymagany",
59 | invalid_type_error: "Numer telefonu musi być tekstem",
60 | })
61 | .min(8, { message: "Numer telefonu musi mieć co najmniej 8 znaków" })
62 | .max(16, { message: "Numer telefonu może mieć maksymalnie 16 znaków" })
63 | .regex(/^\+?[0-9 ]{9,15}$/, {
64 | message:
65 | "Niepoprawny format numeru telefonu. Dozwolone są tylko cyfry i opcjonalnie + na początku",
66 | }),
67 | email: z
68 | .string({
69 | required_error: "Email jest wymagany",
70 | invalid_type_error: "Email musi być tekstem",
71 | })
72 | .email({
73 | message: "Niepoprawny format adresu email",
74 | })
75 | .min(6, { message: "Email musi mieć co najmniej 6 znaków" })
76 | .max(64, { message: "Email może mieć maksymalnie 64 znaki" }),
77 | })
78 |
79 | export const getClinicByIdSchema = z.object({
80 | id: z.string({
81 | required_error: "Id jest wymagane",
82 | invalid_type_error: "Id musi być tekstem",
83 | }),
84 | })
85 |
86 | export const addClinicSchema = clinicSchema
87 |
88 | export const checkIfClinicExistsSchema = z.object({
89 | id: clinicIdSchema,
90 | })
91 |
92 | export const updateClinicSchema = clinicSchema.extend({
93 | id: clinicIdSchema,
94 | })
95 |
96 | export type GetClinicInput = z.infer
97 |
98 | export type AddClinicInput = z.infer
99 |
100 | export type CheckIfClinicExistsInput = z.infer
101 |
102 | export type UpdateClinicInput = z.infer
103 |
--------------------------------------------------------------------------------
/src/components/forms/auth/password-reset-form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { useRouter } from "next/navigation"
5 | import { resetPassword } from "@/actions/auth"
6 | import { zodResolver } from "@hookform/resolvers/zod"
7 | import { useForm } from "react-hook-form"
8 |
9 | import { DEFAULT_UNAUTHENTICATED_REDIRECT } from "@/config/defaults"
10 | import {
11 | passwordResetSchema,
12 | type PasswordResetFormInput,
13 | } from "@/validations/auth"
14 |
15 | import { useToast } from "@/hooks/use-toast"
16 |
17 | import { Button } from "@/components/ui/button"
18 | import {
19 | Form,
20 | FormControl,
21 | FormField,
22 | FormItem,
23 | FormLabel,
24 | FormMessage,
25 | } from "@/components/ui/form"
26 | import { Input } from "@/components/ui/input"
27 | import { Icons } from "@/components/icons"
28 |
29 | export function PasswordResetForm(): JSX.Element {
30 | const router = useRouter()
31 | const { toast } = useToast()
32 | const [isPending, startTransition] = React.useTransition()
33 |
34 | const form = useForm({
35 | resolver: zodResolver(passwordResetSchema),
36 | defaultValues: {
37 | email: "",
38 | },
39 | })
40 |
41 | function onSubmit(formData: PasswordResetFormInput): void {
42 | startTransition(async () => {
43 | try {
44 | const message = await resetPassword({ email: formData.email })
45 |
46 | switch (message) {
47 | case "not-found":
48 | toast({
49 | title: "Użytkownik o podanym adresie email nie istnieje",
50 | variant: "destructive",
51 | })
52 | form.reset()
53 | break
54 | case "success":
55 | toast({
56 | title: "Link został wysłany",
57 | description: "Sprawdź maila aby dokończyć resetowanie hasła",
58 | })
59 | router.push(DEFAULT_UNAUTHENTICATED_REDIRECT)
60 | break
61 | default:
62 | toast({
63 | title: "Błąd przy resetowaniu kasła",
64 | description: "Spróbuj ponownie",
65 | variant: "destructive",
66 | })
67 | router.push(DEFAULT_UNAUTHENTICATED_REDIRECT)
68 | }
69 | } catch (error) {
70 | toast({
71 | title: "Coś poszło nie tak",
72 | description: "Spróbuj ponownie",
73 | variant: "destructive",
74 | })
75 | console.error(error)
76 | }
77 | })
78 | }
79 |
80 | return (
81 |
115 |
116 | )
117 | }
118 |
--------------------------------------------------------------------------------
/src/validations/auth.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod"
2 |
3 | import { emailSchema } from "@/validations/email"
4 |
5 | export const userIdSchema = z
6 | .string({
7 | required_error: "Id użytkownika jest wymagane",
8 | invalid_type_error: "Dane wejściowe muszą być tekstem",
9 | })
10 | .min(1, {
11 | message: "Id musi mieć przynajmniej 1 znak",
12 | })
13 | .max(128, {
14 | message: "Id może mieć maksymalnie 128 znaków",
15 | })
16 |
17 | export const passwordSchema = z
18 | .string({
19 | required_error: "Hasło jest wymagane",
20 | invalid_type_error: "Nieprawidłowy typ danych",
21 | })
22 | .min(8, {
23 | message: "Hasło musi się składać z przynajmniej 8 znaków",
24 | })
25 | .max(256, {
26 | message: "Hasło nie może mieć więcej niż 256 znaków",
27 | })
28 |
29 | export const signUpWithPasswordSchema = z
30 | .object({
31 | email: emailSchema,
32 | password: passwordSchema.regex(
33 | /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/,
34 | {
35 | message:
36 | "Hasło musi mieć od 8 do 256 znaków, zawierać przynajmniej jedną wielką literę, jedną małą literę, jedną liczbę, oraz jedną znak specjalny",
37 | }
38 | ),
39 | confirmPassword: z.string(),
40 | })
41 | .refine((schema) => schema.password === schema.confirmPassword, {
42 | message: "Podane hasła są różne",
43 | path: ["confirmPassword"],
44 | })
45 |
46 | export const signInWithPasswordSchema = z.object({
47 | email: emailSchema,
48 | password: z.string({
49 | required_error: "Hasło jest wymagane",
50 | invalid_type_error: "Nieprawidłowy typ danych",
51 | }),
52 | })
53 |
54 | export const passwordResetSchema = z.object({
55 | email: emailSchema,
56 | })
57 |
58 | export const passwordUpdateSchema = z
59 | .object({
60 | password: passwordSchema.regex(
61 | /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/,
62 | {
63 | message:
64 | "Hasło musi mieć od 8 do 256 znaków, zawierać przynajmniej jedną wielką literę, jedną małą literę, jedną liczbę, oraz jedną znak specjalny",
65 | }
66 | ),
67 | confirmPassword: z.string(),
68 | })
69 | .refine((schema) => schema.password === schema.confirmPassword, {
70 | message: "Podane hasła są różne",
71 | path: ["confirmPassword"],
72 | })
73 |
74 | export const passwordUpdateSchemaExtended = z
75 | .object({
76 | password: passwordSchema.regex(
77 | /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/,
78 | {
79 | message:
80 | "Hasło musi mieć od 8 do 256 znaków, zawierać przynajmniej jedną wielką literę, jedną małą literę, jedną liczbę, oraz jedną znak specjalny",
81 | }
82 | ),
83 | confirmPassword: z.string(),
84 | resetPasswordToken: z
85 | .string({
86 | required_error: "Token do resetowania hasła jest wymagany",
87 | invalid_type_error: "Nieprawidłowy typ danych",
88 | })
89 | .min(16)
90 | .max(512),
91 | })
92 | .refine((schema) => schema.password === schema.confirmPassword, {
93 | message: "Podane hasła są różne",
94 | path: ["confirmPassword"],
95 | })
96 |
97 | export const linkOAuthAccountSchema = z.object({
98 | userId: userIdSchema,
99 | })
100 |
101 | export type SignUpWithPasswordFormInput = z.infer<
102 | typeof signUpWithPasswordSchema
103 | >
104 |
105 | export type SignInWithPasswordFormInput = z.infer<
106 | typeof signInWithPasswordSchema
107 | >
108 |
109 | export type PasswordResetFormInput = z.infer
110 |
111 | export type PasswordUpdateFormInput = z.infer
112 |
113 | export type PasswordUpdateFormInputExtended = z.infer<
114 | typeof passwordUpdateSchemaExtended
115 | >
116 |
117 | export type LinkOAuthAccountInput = z.infer
118 |
--------------------------------------------------------------------------------
/src/components/data-table/data-table-loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton"
2 | import {
3 | Table,
4 | TableBody,
5 | TableCell,
6 | TableHead,
7 | TableHeader,
8 | TableRow,
9 | } from "@/components/ui/table"
10 |
11 | interface DataTableLoadingProps {
12 | columnCount: number
13 | rowCount?: number
14 | isNewRowCreatable?: boolean
15 | isRowsDeletable?: boolean
16 | searchableFieldCount?: number
17 | filterableFieldCount?: number
18 | }
19 |
20 | export function DataTableLoading({
21 | columnCount,
22 | rowCount = 10,
23 | isNewRowCreatable = false,
24 | isRowsDeletable = false,
25 | searchableFieldCount = 1,
26 | filterableFieldCount = 1,
27 | }: DataTableLoadingProps): JSX.Element {
28 | return (
29 |
30 |
31 |
32 | {searchableFieldCount > 0
33 | ? Array.from({ length: searchableFieldCount }).map((_, i) => (
34 |
35 | ))
36 | : null}
37 | {filterableFieldCount > 0
38 | ? Array.from({ length: filterableFieldCount }).map((_, i) => (
39 |
40 | ))
41 | : null}
42 |
43 |
44 | {isRowsDeletable ? (
45 |
46 | ) : isNewRowCreatable ? (
47 |
48 | ) : null}
49 |
50 |
51 |
52 |
53 |
54 |
55 | {Array.from({ length: 1 }).map((_, i) => (
56 |
57 | {Array.from({ length: columnCount }).map((_, i) => (
58 |
59 |
60 |
61 | ))}
62 |
63 | ))}
64 |
65 |
66 | {Array.from({ length: rowCount }).map((_, i) => (
67 |
68 | {Array.from({ length: columnCount }).map((_, j) => (
69 |
70 |
71 |
72 | ))}
73 |
74 | ))}
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | )
100 | }
101 |
--------------------------------------------------------------------------------
/src/components/date-range-picker.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { usePathname, useRouter, useSearchParams } from "next/navigation"
5 | import { addDays, format } from "date-fns"
6 | import { pl } from "date-fns/locale"
7 | import type { DateRange } from "react-day-picker"
8 |
9 | import { cn } from "@/lib/utils"
10 |
11 | import { Button } from "@/components/ui/button"
12 | import { Calendar } from "@/components/ui/calendar"
13 | import {
14 | Popover,
15 | PopoverContent,
16 | PopoverTrigger,
17 | } from "@/components/ui/popover"
18 | import { Icons } from "@/components/icons"
19 |
20 | interface DateRangePickerProps extends React.HTMLAttributes {
21 | dateRange?: DateRange
22 | dayCount?: number
23 | align?: "center" | "start" | "end"
24 | }
25 |
26 | export function DateRangePicker({
27 | dateRange,
28 | dayCount,
29 | align = "start",
30 | className,
31 | ...props
32 | }: DateRangePickerProps): JSX.Element {
33 | const router = useRouter()
34 | const pathname = usePathname()
35 | const searchParams = useSearchParams()
36 |
37 | const [from, to] = React.useMemo(() => {
38 | let fromDay: Date | undefined
39 | let toDay: Date | undefined
40 |
41 | if (dateRange) {
42 | fromDay = dateRange.from
43 | toDay = dateRange.to
44 | } else if (dayCount) {
45 | toDay = new Date()
46 | fromDay = addDays(toDay, -dayCount)
47 | }
48 |
49 | return [fromDay, toDay]
50 | }, [dateRange, dayCount])
51 |
52 | const [date, setDate] = React.useState({ from, to })
53 |
54 | // Create query string
55 | const createQueryString = React.useCallback(
56 | (params: Record) => {
57 | const newSearchParams = new URLSearchParams(searchParams?.toString())
58 |
59 | for (const [key, value] of Object.entries(params)) {
60 | if (value === null) {
61 | newSearchParams.delete(key)
62 | } else {
63 | newSearchParams.set(key, String(value))
64 | }
65 | }
66 |
67 | return newSearchParams.toString()
68 | },
69 | [searchParams]
70 | )
71 |
72 | // Update query string
73 | React.useEffect(() => {
74 | router.push(
75 | `${pathname}?${createQueryString({
76 | from: date?.from ? format(date.from, "yyyy-MM-dd") : null,
77 | to: date?.to ? format(date.to, "yyyy-MM-dd") : null,
78 | })}`,
79 | {
80 | scroll: false,
81 | }
82 | )
83 | // eslint-disable-next-line react-hooks/exhaustive-deps
84 | }, [date?.from, date?.to])
85 |
86 | return (
87 |
88 |
89 |
90 |
98 |
99 | {date?.from ? (
100 | date.to ? (
101 | <>
102 | {format(date.from, "LLL dd, y")} -{" "}
103 | {format(date.to, "LLL dd, y")}
104 | >
105 | ) : (
106 | format(date.from, "LLL dd, y")
107 | )
108 | ) : (
109 | Wybierz datę
110 | )}
111 |
112 |
113 |
114 |
123 |
124 |
125 |
126 | )
127 | }
128 |
--------------------------------------------------------------------------------
/src/components/data-table/data-table-pagination.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ChevronLeftIcon,
3 | ChevronRightIcon,
4 | DoubleArrowLeftIcon,
5 | DoubleArrowRightIcon,
6 | } from "@radix-ui/react-icons"
7 | import { type Table } from "@tanstack/react-table"
8 |
9 | import { Button } from "@/components/ui/button"
10 | import {
11 | Select,
12 | SelectContent,
13 | SelectItem,
14 | SelectTrigger,
15 | SelectValue,
16 | } from "@/components/ui/select"
17 |
18 | interface DataTablePaginationProps {
19 | table: Table
20 | pageSizeOptions?: number[]
21 | }
22 |
23 | export function DataTablePagination({
24 | table,
25 | pageSizeOptions = [10, 20, 30, 40, 50],
26 | }: DataTablePaginationProps): JSX.Element {
27 | return (
28 |
29 |
30 | zaznaczonych {table.getFilteredSelectedRowModel().rows.length} z{" "}
31 | {table.getFilteredRowModel().rows.length} rezerwacji
32 |
33 |
34 |
35 |
36 | Liczba rezerwacji na stronę
37 |
38 |
{
41 | table.setPageSize(Number(value))
42 | }}
43 | >
44 |
45 |
46 |
47 |
48 | {pageSizeOptions.map((pageSize) => (
49 |
50 | {pageSize}
51 |
52 | ))}
53 |
54 |
55 |
56 |
57 | Page {table.getState().pagination.pageIndex + 1} of{" "}
58 | {table.getPageCount()}
59 |
60 |
61 | table.setPageIndex(0)}
67 | disabled={!table.getCanPreviousPage()}
68 | >
69 |
70 |
71 | table.previousPage()}
77 | disabled={!table.getCanPreviousPage()}
78 | >
79 |
80 |
81 | table.nextPage()}
87 | disabled={!table.getCanNextPage()}
88 | >
89 |
90 |
91 | table.setPageIndex(table.getPageCount() - 1)}
97 | disabled={!table.getCanNextPage()}
98 | >
99 |
100 |
101 |
102 |
103 |
104 | )
105 | }
106 |
--------------------------------------------------------------------------------
/src/components/forms/auth/email-verification-form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { useRouter } from "next/navigation"
5 | import { resendEmailVerificationLink } from "@/actions/email"
6 | import { zodResolver } from "@hookform/resolvers/zod"
7 | import { useForm } from "react-hook-form"
8 |
9 | import { DEFAULT_UNAUTHENTICATED_REDIRECT } from "@/config/defaults"
10 | import {
11 | emailVerificationSchema,
12 | type EmailVerificationFormInput,
13 | } from "@/validations/email"
14 |
15 | import { useToast } from "@/hooks/use-toast"
16 |
17 | import { Button } from "@/components/ui/button"
18 | import {
19 | Form,
20 | FormControl,
21 | FormField,
22 | FormItem,
23 | FormLabel,
24 | FormMessage,
25 | } from "@/components/ui/form"
26 | import { Input } from "@/components/ui/input"
27 | import { Icons } from "@/components/icons"
28 |
29 | export function EmailVerificationForm(): JSX.Element {
30 | const router = useRouter()
31 | const { toast } = useToast()
32 | const [isPending, startTransition] = React.useTransition()
33 |
34 | const form = useForm({
35 | resolver: zodResolver(emailVerificationSchema),
36 | defaultValues: {
37 | email: "",
38 | },
39 | })
40 |
41 | function onSubmit(formData: EmailVerificationFormInput): void {
42 | startTransition(async () => {
43 | try {
44 | const message = await resendEmailVerificationLink({
45 | email: formData.email,
46 | })
47 |
48 | switch (message) {
49 | case "not-found":
50 | toast({
51 | title: "Użytkownik z podanym adresem email nie istnieje",
52 | variant: "destructive",
53 | })
54 | form.reset()
55 | break
56 | case "verified":
57 | toast({
58 | title: "Twój email jest już zweryfikowany",
59 | description: "Wróć do strony logowania i spróbuj się zalogować",
60 | })
61 | break
62 | case "success":
63 | toast({
64 | title: "Link weryfikacyjny został wysłany",
65 | description:
66 | "Kliknij w otrzymanego linka w celu dokończenia weryfikacji",
67 | })
68 | router.push(DEFAULT_UNAUTHENTICATED_REDIRECT)
69 | break
70 | default:
71 | toast({
72 | title: "Błąd przy wysyłaniu linka weryfikacyjnego",
73 | description: "Spróbuj ponownie",
74 | variant: "destructive",
75 | })
76 | router.push("/rejestracja")
77 | }
78 | } catch (error) {
79 | toast({
80 | title: "Coś poszło nie tak",
81 | description: "Spróbuj ponownie",
82 | variant: "destructive",
83 | })
84 | console.error(error)
85 | }
86 | })
87 | }
88 |
89 | return (
90 |
126 |
127 | )
128 | }
129 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./src/**/*.{js,ts,jsx,tsx}"],
4 | darkMode: ["class"],
5 | theme: {
6 | container: {
7 | center: true,
8 | padding: "2rem",
9 | screens: {
10 | "2xl": "1400px",
11 | },
12 | },
13 | extend: {
14 | screens: {
15 | xs: "380px",
16 | "w-400": "400px",
17 | "w-590": "590px",
18 | "w-768": "768px",
19 | "w-1170": "1170px",
20 | "w-1400": "1400px",
21 | "w-1496": "1496px",
22 | },
23 | flex: {
24 | full: "0 0 100%",
25 | },
26 | fontFamily: {
27 | BalooTamma: ["BalooTamma", "sans-serif"],
28 | Baloo: ["Baloo", "cursive"],
29 | },
30 | colors: {
31 | border: "hsl(var(--border))",
32 | input: "hsl(var(--input))",
33 | ring: "hsl(var(--ring))",
34 | background: "hsl(var(--background))",
35 | foreground: "hsl(var(--foreground))",
36 | primary: {
37 | DEFAULT: "hsl(var(--primary))",
38 | foreground: "hsl(var(--primary-foreground))",
39 | },
40 | secondary: {
41 | DEFAULT: "hsl(var(--secondary))",
42 | foreground: "hsl(var(--secondary-foreground))",
43 | },
44 | destructive: {
45 | DEFAULT: "hsl(var(--destructive))",
46 | foreground: "hsl(var(--destructive-foreground))",
47 | },
48 | muted: {
49 | DEFAULT: "hsl(var(--muted))",
50 | foreground: "hsl(var(--muted-foreground))",
51 | },
52 | accent: {
53 | DEFAULT: "hsl(var(--accent))",
54 | foreground: "hsl(var(--accent-foreground))",
55 | },
56 | popover: {
57 | DEFAULT: "hsl(var(--popover))",
58 | foreground: "hsl(var(--popover-foreground))",
59 | },
60 | card: {
61 | DEFAULT: "var(--card))",
62 | foreground: "hsl(var(--card-foreground))",
63 | },
64 | peach: "var(--peach)",
65 | peachServicesText: "var(--peach-services-text)",
66 | greenGradientFrom: "var(--green-gradient-from)",
67 | greenGradientTo: "var(--green-gradient-to)",
68 | whiteGradientFrom: "var(--white-gradient-from)",
69 | whiteGradientTo: "var(--white-gradient-to)",
70 | primaryGreen: "var(--primary-green)",
71 | secondaryGreen: "var(--secondary-green)",
72 | greenServicesText: "var(--green-services-text)",
73 | greenNavbarText: "var(--green-navbar-text)",
74 | greenNavbarHover: "var(--green-navbar-hover)",
75 | greenButtonBackground: "var(--green-button-background)",
76 | greenButtonText: "var(--green-button-text)",
77 | greenContactFormText: "var(--green-contact-form-text)",
78 | locationText: "var(--location-text)",
79 | primaryButtonBorder: "var(--primary-button-border)",
80 | secondaryButtonBorder: "var(--secondary-button-border)",
81 | appointmentButtonBorder: "var(--appointment-button-border)",
82 | contactButtonBorder: "var(--contact-button-border)",
83 | contactButtonBackground: "var(--contact-button-background)",
84 | offWhiteText: "var(--off-white-text)",
85 | lightSectionText: "var(--light-section-text)",
86 | darkSectionText: "var(--dark-section-text)",
87 | },
88 | borderRadius: {
89 | lg: "var(--radius)",
90 | md: "calc(var(--radius) - 2px)",
91 | sm: "calc(var(--radius) - 4px)",
92 | },
93 | keyframes: {
94 | "accordion-down": {
95 | from: { height: 0 },
96 | to: { height: "var(--radix-accordion-content-height)" },
97 | },
98 | "accordion-up": {
99 | from: { height: "var(--radix-accordion-content-height)" },
100 | to: { height: 0 },
101 | },
102 | },
103 | animation: {
104 | "accordion-down": "accordion-down 0.2s ease-out",
105 | "accordion-up": "accordion-up 0.2s ease-out",
106 | },
107 | },
108 | },
109 | plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
110 | }
111 |
--------------------------------------------------------------------------------
/src/actions/clinic.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { unstable_noStore as noStore, revalidatePath } from "next/cache"
4 | import { eq } from "drizzle-orm"
5 |
6 | import { db } from "@/config/db"
7 | import {
8 | psCheckIfClinicExists,
9 | psGetClinic,
10 | } from "@/db/prepared-statements/clinic"
11 | import { clinics, type Clinic } from "@/db/schema"
12 | import {
13 | addClinicSchema,
14 | checkIfClinicExistsSchema,
15 | updateClinicSchema,
16 | type AddClinicInput,
17 | type CheckIfClinicExistsInput,
18 | type UpdateClinicInput,
19 | } from "@/validations/clinic"
20 |
21 | import { generateId } from "@/lib/utils"
22 |
23 | export async function addClinic(
24 | rawInput: AddClinicInput
25 | ): Promise<"invalid-input" | "error" | "success"> {
26 | try {
27 | const validatedInput = addClinicSchema.safeParse(rawInput)
28 | if (!validatedInput.success) return "invalid-input"
29 |
30 | const newClinic = await db
31 | .insert(clinics)
32 | .values({
33 | id: generateId(),
34 | latitude: validatedInput.data.latitude,
35 | longitude: validatedInput.data.longitude,
36 | address: validatedInput.data.address,
37 | phone_1: validatedInput.data.phone_1,
38 | phone_2: validatedInput.data.phone_2,
39 | email: validatedInput.data.email,
40 | })
41 | .returning()
42 |
43 | revalidatePath("/admin/przychodnia")
44 | revalidatePath("/")
45 |
46 | return newClinic ? "success" : "error"
47 | } catch (error) {
48 | console.error(error)
49 | throw new Error("Błąd przy dodawaniu nowej kliniki")
50 | }
51 | }
52 |
53 | export async function getClinic(): Promise {
54 | try {
55 | noStore()
56 | let [clinic] = await psGetClinic.execute()
57 |
58 | if (!clinic) {
59 | const newClinic = await db
60 | .insert(clinics)
61 | .values({
62 | id: generateId(),
63 | latitude: "49.963502626301796",
64 | longitude: "20.41957162751482",
65 | address: "Brodzińskiego 2, 32-700 Bochnia",
66 | phone_1: "14 61 16 499",
67 | phone_2: "501 01 45 54",
68 | email: "pjborowiecki@poutlook.com",
69 | })
70 | .returning()
71 |
72 | if (newClinic) {
73 | ;[clinic] = await psGetClinic.execute()
74 | }
75 | }
76 |
77 | return clinic || null
78 | } catch (error) {
79 | console.error(error)
80 | throw new Error("Błąd wczytywania danych przychodni")
81 | }
82 | }
83 |
84 | export async function checkIfClinicExists(
85 | rawInput: CheckIfClinicExistsInput
86 | ): Promise<"invalid-input" | boolean> {
87 | try {
88 | const validatedInput = checkIfClinicExistsSchema.safeParse(rawInput)
89 | if (!validatedInput.success) return "invalid-input"
90 |
91 | noStore()
92 | const exists = await psCheckIfClinicExists.execute({
93 | id: validatedInput.data.id,
94 | })
95 |
96 | return exists ? true : false
97 | } catch (error) {
98 | console.error(error)
99 | throw new Error("Error checking if clinic exists")
100 | }
101 | }
102 |
103 | export async function updateClinic(
104 | rawInput: UpdateClinicInput
105 | ): Promise<"invalid-input" | "not-found" | "error" | "success"> {
106 | try {
107 | const validatedInput = updateClinicSchema.safeParse(rawInput)
108 | if (!validatedInput.success) return "invalid-input"
109 |
110 | const exists = await checkIfClinicExists({ id: validatedInput.data.id })
111 | if (!exists || exists === "invalid-input") return "not-found"
112 |
113 | noStore()
114 | const clinicUpdated = await db
115 | .update(clinics)
116 | .set({
117 | latitude: validatedInput.data.latitude,
118 | longitude: validatedInput.data.longitude,
119 | address: validatedInput.data.address,
120 | phone_1: validatedInput.data.phone_1,
121 | phone_2: validatedInput.data.phone_2,
122 | email: validatedInput.data.email,
123 | })
124 | .where(eq(clinics.id, validatedInput.data.id))
125 | .returning()
126 |
127 | revalidatePath("/admin/przychodnia")
128 | revalidatePath("/")
129 |
130 | return clinicUpdated ? "success" : "error"
131 | } catch (error) {
132 | console.error(error)
133 | throw new Error("Błąd przy aktualizacji danych przychodni")
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { Cross2Icon } from "@radix-ui/react-icons"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogTrigger,
116 | DialogClose,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/src/app/(auth)/logowanie/haslo-aktualizacja/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next"
2 | import Link from "next/link"
3 | import { getUserByResetPasswordToken } from "@/actions/user"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | import { buttonVariants } from "@/components/ui/button"
8 | import {
9 | Card,
10 | CardContent,
11 | CardDescription,
12 | CardHeader,
13 | CardTitle,
14 | } from "@/components/ui/card"
15 | import { PasswordUpdateForm } from "@/components/forms/auth/password-update-form"
16 | import { Icons } from "@/components/icons"
17 |
18 | export const metadata: Metadata = {
19 | metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL),
20 | title: "Aktualizacja hasła",
21 | description: "Ustaw nowe hasło",
22 | }
23 |
24 | interface PasswordUpdatePageProps {
25 | searchParams: { [key: string]: string | string[] | undefined }
26 | }
27 |
28 | export default async function PasswordUpdatePage({
29 | searchParams,
30 | }: Readonly): Promise {
31 | if (searchParams.token) {
32 | const user = await getUserByResetPasswordToken({
33 | token: String(searchParams.token),
34 | })
35 |
36 | if (!user) {
37 | return (
38 |
39 |
40 |
41 | Błędny kod resetujący
42 |
43 | Wróć do strony logowania i spróbuj ponownie
44 |
45 |
46 |
47 |
55 |
56 | Spróbuj ponownie
57 | Spróbuj ponownie
58 |
59 |
60 |
61 |
62 | )
63 | }
64 |
65 | return (
66 |
67 |
68 |
69 | Aktualizacja hasła
70 | Ustaw swoje nowe hasło
71 |
72 |
73 |
76 |
81 | Anuluj aktualizację hasła
82 | Anuluj
83 |
84 |
85 |
86 |
87 | )
88 | } else {
89 | return (
90 |
91 |
92 |
93 | Brak kodu resetującego
94 |
95 | Wróć do strony logowania i spróbuj ponownie
96 |
97 |
98 |
99 |
104 |
105 | Spróbuj ponownie
106 | Spróbuj ponownie
107 |
108 |
109 |
110 |
111 | )
112 | }
113 | }
114 |
--------------------------------------------------------------------------------