Locations added

This commit is contained in:
cdricms
2025-03-10 16:25:12 +01:00
parent 7cb633b4c6
commit 4cf85981eb
32 changed files with 1504 additions and 227 deletions

View File

@@ -3,7 +3,6 @@ package events
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"fr.latosa-escrima/core" "fr.latosa-escrima/core"
@@ -32,8 +31,6 @@ func HandleUpdate(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Println(event)
_, err = core.DB.NewUpdate(). _, err = core.DB.NewUpdate().
Model(&event). Model(&event).
OmitZero(). OmitZero().

View File

@@ -0,0 +1,38 @@
package locations
import (
"context"
"encoding/json"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
)
func HandleDelete(w http.ResponseWriter, r *http.Request) {
var location models.Location
if err := json.NewDecoder(r.Body).Decode(&location); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
if _, err := core.DB.
NewDelete().
Model(&location).
WherePK().
Exec(context.Background()); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
core.JSONSuccess{
Status: core.Success,
Message: "Location deleted.",
}.Respond(w, http.StatusOK)
}

View File

@@ -0,0 +1,56 @@
package locations
import (
"context"
"encoding/json"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
)
func HandleLocation(w http.ResponseWriter, r *http.Request) {
var location models.Location
if err := json.NewDecoder(r.Body).Decode(&location); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
err := core.DB.
NewSelect().
Model(&location).
Where("street = ? AND city = ? AND postal_code = ?", location.Street, location.City, location.PostalCode).
Scan(context.Background())
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
var events []models.Event
err = core.DB.
NewSelect().
Model(&events).
Where("location = ? || ', ' || ? || ', ' || ?", location.Street, location.City, location.PostalCode).
Scan(context.Background())
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
location.Events = &events
core.JSONSuccess{
Status: core.Success,
Message: "Location retrieved.",
Data: location,
}.Respond(w, http.StatusOK)
}

View File

@@ -0,0 +1,46 @@
package locations
import (
"context"
"fmt"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
"fr.latosa-escrima/utils"
)
func HandleLocations(w http.ResponseWriter, r *http.Request) {
var locations []*models.Location
if err := core.DB.
NewSelect().
Model(&locations).
Scan(context.Background()); err != nil {
fmt.Println("Error")
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
locations = utils.Map(locations, func(l *models.Location) *models.Location {
var events []models.Event
err := core.DB.
NewSelect().
Model(&events).
Where("location = ? || ', ' || ? || ', ' || ?", l.Street, l.City, l.PostalCode).
Scan(context.Background())
if err != nil {
return nil
}
l.Events = &events
return l
})
core.JSONSuccess{
Status: core.Success,
Message: "Locations retrieved.",
Data: locations,
}.Respond(w, http.StatusOK)
}

View File

@@ -0,0 +1,38 @@
package locations
import (
"context"
"encoding/json"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
)
func HandleNew(w http.ResponseWriter, r *http.Request) {
var location models.Location
if err := json.NewDecoder(r.Body).Decode(&location); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
if _, err := core.DB.
NewInsert().
Model(&location).
Ignore().
Exec(context.Background()); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
core.JSONSuccess{
Status: core.Success,
Message: "Location created.",
}.Respond(w, http.StatusCreated)
}

View File

@@ -0,0 +1,41 @@
package locations
import (
"context"
"encoding/json"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
)
func HandleUpdate(w http.ResponseWriter, r *http.Request) {
var location models.Location
if err := json.NewDecoder(r.Body).Decode(&location); err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
_, err := core.DB.
NewUpdate().
Model(&location).
WherePK().
OmitZero().
Exec(context.Background())
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
core.JSONSuccess{
Status: core.Success,
Message: "Location updated",
Data: location,
}.Respond(w, http.StatusOK)
}

View File

@@ -0,0 +1,30 @@
package api
import (
"fr.latosa-escrima/api/locations"
"fr.latosa-escrima/core"
)
var LocationsRoutes = map[string]core.Handler{
"GET /locations/all": {
Handler: locations.HandleLocations,
Middlewares: []core.Middleware{Methods("GET")},
},
"/locations/new": {
Handler: locations.HandleNew,
Middlewares: []core.Middleware{Methods(("POST")),
HasPermissions("locations", "insert"), AuthJWT}},
"GET /locations": {
Handler: locations.HandleLocation,
Middlewares: []core.Middleware{Methods("GET")}},
"DELETE /locations": {
Handler: locations.HandleDelete,
Middlewares: []core.Middleware{Methods("DELETE"),
HasPermissions("locations", "delete"), AuthJWT},
},
"PATCH /locations": {
Handler: locations.HandleUpdate,
Middlewares: []core.Middleware{Methods("PATCH"),
HasPermissions("blogs", "update"), AuthJWT},
},
}

View File

@@ -0,0 +1,30 @@
package migrations
import (
"context"
"fmt"
"fr.latosa-escrima/core/models"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
fmt.Print(" [up migration] ")
_, err := db.
NewAddColumn().
Model((*models.Event)(nil)).
ColumnExpr("description TEXT").
Exec(ctx)
_, err = db.
NewAddColumn().
Model((*models.Event)(nil)).
ColumnExpr("location TEXT").
Exec(ctx)
return err
}, func(ctx context.Context, db *bun.DB) error {
fmt.Print(" [down migration] ")
return nil
})
}

View File

@@ -17,5 +17,7 @@ type Event struct {
ScheduleEnd time.Time `bun:"schedule_end,notnull" json:"end"` ScheduleEnd time.Time `bun:"schedule_end,notnull" json:"end"`
FullDay bool `bun:"full_day,notnull,default:false" json:"fullDay"` FullDay bool `bun:"full_day,notnull,default:false" json:"fullDay"`
IsVisible bool `bun:"is_visible,notnull,default:true" json:"isVisible"` IsVisible bool `bun:"is_visible,notnull,default:true" json:"isVisible"`
Description *string `bun:"description" json:"description,omitempty"`
Location *string `bun:"location" json:"location,omitempty"`
Rrule string `bun:"rrule" json:"rrule"` Rrule string `bun:"rrule" json:"rrule"`
} }

View File

@@ -0,0 +1,16 @@
package models
import "github.com/uptrace/bun"
type Location struct {
bun.BaseModel `bun:"table:locations"`
ID int `bun:"id,pk,autoincrement" json:"id"`
Street string `bun:"street,notnull,unique:location" json:"street"`
City string `bun:"city,notnull,unique:location" json:"city"`
PostalCode string `bun:"postal_code,notnull,unique:location" json:"postalCode"`
Latitude *float64 `bun:"latitude" json:"latitude,omitempty"`
Longitude *float64 `bun:"longitude" json:"longitude,omitempty"`
Events *[]Event `bun:"-" json:"events,omitempty"`
}

View File

@@ -10,7 +10,7 @@ import (
type Permissions []models.Permission type Permissions []models.Permission
func GetAllPermissions() Permissions { func GetAllPermissions() Permissions {
resources := []string{"users", "roles", "media", "events", "permissions", "shortcodes", "blogs"} resources := []string{"users", "roles", "media", "events", "permissions", "shortcodes", "blogs", "locations"}
var perms Permissions var perms Permissions
for _, resource := range resources { for _, resource := range resources {
perms = append(perms, Permissions{ perms = append(perms, Permissions{

View File

@@ -72,6 +72,9 @@ func InitDatabase(dsn DSN) (*bun.DB, error) {
_, err = db.NewCreateTable(). _, err = db.NewCreateTable().
Model((*m.UserToRole)(nil)).IfNotExists().Exec(ctx) Model((*m.UserToRole)(nil)).IfNotExists().Exec(ctx)
_, err = db.NewCreateTable().
Model((*m.Location)(nil)).IfNotExists().Exec(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -81,7 +81,8 @@ func main() {
api.MediaRoutes, api.MediaRoutes,
api.PermissionsRoutes, api.PermissionsRoutes,
api.RolesRoutes, api.RolesRoutes,
api.ShortcodesRoutes) api.ShortcodesRoutes,
api.LocationsRoutes)
core.HandleRoutes(mux, routes) core.HandleRoutes(mux, routes)
fmt.Printf("Serving on port %s\n", port) fmt.Printf("Serving on port %s\n", port)

View File

@@ -0,0 +1,89 @@
"use client";
import LocationDialog from "@/components/locations/location-dialog";
import IUser from "@/interfaces/IUser";
import hasPermissions from "@/lib/hasPermissions";
import { useState } from "react";
import { Location } from "@/types/types";
import request from "@/lib/request";
import { useApi } from "@/hooks/use-api";
import { LocationCard } from "@/components/locations/location-card";
export default function LocationsPage({ user }: { user: IUser }) {
const { locations: locationsPerm } = hasPermissions(user.roles, {
locations: ["update", "insert", "delete"],
} as const);
const locations = useApi<Location[]>("/locations/all");
const onUpdate = async (l: Location) => {
try {
const res = await request("/locations", {
method: "PATCH",
body: l,
requiresAuth: true,
});
if (res.status === "Success") {
locations.mutate();
} else {
}
} catch (e) {
console.error(e);
}
};
const onDelete = async (l: Location) => {
try {
const res = await request("/locations", {
method: "DELETE",
body: l,
requiresAuth: true,
});
if (res.status === "Success") locations.mutate();
else {
}
} catch (e) {
console.error(e);
}
};
return (
<div className="p-4 flex flex-col gap-2">
{locationsPerm.insert && (
<div className="self-end">
<LocationDialog
onAdd={async (l) => {
try {
const res = await request("/locations/new", {
body: l,
method: "POST",
requiresAuth: true,
csrfToken: false,
});
if (res.status === "Success") {
locations.mutate();
} else {
}
} catch (e) {}
}}
/>
</div>
)}
<section className="flex flex-wrap gap-2">
{locations.data?.map((l) => {
return (
<LocationCard
key={`${l.city}:${l.street}:${l.postalCode}`}
location={l}
onUpdate={onUpdate}
onDelete={onDelete}
canUpdate={locationsPerm.update}
canDelete={locationsPerm.delete}
/>
);
})}
</section>
</div>
);
}

View File

@@ -0,0 +1,21 @@
"use server";
import getMe from "@/lib/getMe";
import hasPermissions from "@/lib/hasPermissions";
import { redirect } from "next/navigation";
import LocationsPage from "./_locations";
export default async function Page() {
const me = await getMe();
if (
!me ||
me.status === "Error" ||
!me.data ||
!hasPermissions(me.data.roles, {
locations: ["get"],
} as const).all
) {
redirect("/dashboard");
}
return <LocationsPage user={me.data} />;
}

View File

@@ -1,6 +1,10 @@
export const dynamic = "force-dynamic"; // Prevents static rendering export const dynamic = "force-dynamic"; // Prevents static rendering
import { LocationCard } from "@/components/locations/location-card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -12,58 +16,137 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { SITE_NAME } from "@/lib/constants"; import { SITE_NAME } from "@/lib/constants";
import getShortcode from "@/lib/getShortcode"; import getShortcode from "@/lib/getShortcode";
import request from "@/lib/request";
import { Location } from "@/types/types";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
export default async function About() { // Define props interface for PricingCard
const make_contact_div = ( interface PricingCardProps {
<a className="w-full" href="/contact"> title: string;
<Button className="w-full" variant={"outline"}> price: number;
Prendre contact description: string;
features: string[];
isPopular?: boolean;
ctaText?: string;
ctaLink?: string;
}
const UnderConstructionBanner = () => {
return (
<Alert variant="destructive" className="rounded-2xl shadow-md mb-4">
<Info className="h-5 w-5 text-red-500 mr-2" />
<div>
<AlertTitle>Attention</AlertTitle>
<AlertDescription>
Cette page est encore en cours de construction et les
informations listées peuvent ne pas être exactes pour le
moment.
</AlertDescription>
</div>
</Alert>
);
};
// Reusable Pricing Card Component
const PricingCard: React.FC<PricingCardProps> = ({
title,
price,
description,
features,
isPopular = false,
ctaText = "Prendre contact",
ctaLink = "/contact",
}) => (
<Card
className={`border transition-all duration-300 hover:shadow-lg flex-1 max-w-md ${
isPopular ? "border-primary shadow-lg" : "shadow-sm"
}`}
>
<CardHeader className="text-center pb-2">
{isPopular && (
<Badge className="uppercase w-max self-center mb-3 bg-gradient-to-r from-indigo-500 to-purple-500">
Le plus populaire
</Badge>
)}
<CardTitle className="mb-4 text-2xl">{title}</CardTitle>
<span className="font-bold text-4xl">{price}</span>
</CardHeader>
<CardDescription className="text-center w-11/12 mx-auto">
{description}
</CardDescription>
<CardContent>
<ul className="mt-6 space-y-3 text-sm">
{features.map((feature, index) => (
<li key={index} className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4 text-green-500" />
<span className="text-muted-foreground">{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<a href={ctaLink} className="w-full">
<Button
className={`w-full transition-all duration-300 ${
isPopular
? "bg-gradient-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600"
: "bg-gray-800 hover:bg-gray-900"
}`}
aria-label={`Contact us for ${title} plan`}
>
{ctaText}
</Button> </Button>
</a> </a>
); </CardFooter>
</Card>
);
export default async function About() {
const profileImage = await getShortcode("profile_image"); const profileImage = await getShortcode("profile_image");
const locations = await request<Location[]>("/locations/all", {
requiresAuth: false,
});
return ( return (
<> <div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<div className=""> {/* Hero Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 w-full p-12 items-stretch"> <div className="px-6 py-12 lg:py-16 lg:px-12">
<UnderConstructionBanner />
</div>
<section className="grid grid-cols-1 lg:grid-cols-3 gap-6 w-full px-6 py-12 lg:px-12 lg:py-16 items-stretch">
{/* Text Section - Takes 2/3 on large screens */} {/* Text Section - Takes 2/3 on large screens */}
<div className="lg:col-span-2 flex flex-col justify-center"> <div className="lg:col-span-2 flex flex-col justify-center">
<Card className="h-full"> <Card className="h-full shadow-md transition-all duration-300 hover:shadow-xl">
<CardHeader className="text-center p-4"> <CardHeader className="text-center p-6">
<CardTitle className="text-5xl"> <CardTitle className="text-4xl lg:text-5xl font-bold">
Nicolas GORUK Nicolas GORUK
</CardTitle> </CardTitle>
<CardDescription> <CardDescription className="text-lg mt-2">
Président de l'association française de{" "} Président de l'association française de{" "}
{SITE_NAME} {SITE_NAME}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="px-8 sm:px-10 py-14"> <CardContent className="px-6 sm:px-10 py-12 space-y-6">
<div className="flex flex-col gap-4 justify-center"> <div className="flex flex-col gap-6 justify-center text-justify">
<h2 className="text-center text-xl font-semibold sm:text-3xl"> <h2 className="text-center text-xl font-semibold sm:text-2xl">
Lorem ipsum, dolor sit amet Notre mission
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground text-base">
Lorem ipsum dolor sit amet consectetur Chez {SITE_NAME}, nous nous engageons à
adipisicing elit. Debitis accusamus promouvoir l'excellence. Nous offrons un
illum, nam nemo quod delectus velit environnement dynamique pour tous nos
repellat odio dolorum sapiente soluta, membres, avec des événements réguliers et
aliquam atque praesentium ea placeat ad, des opportunités uniques.
neque eveniet adipisci?
</p> </p>
<h2 className="text-center text-xl font-semibold sm:text-3xl"> <h2 className="text-center text-xl font-semibold sm:text-2xl">
Lorem ipsum, dolor sit amet Notre histoire
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground text-base">
Lorem ipsum dolor sit amet consectetur Fondée en [année], notre association a
adipisicing elit. Debitis accusamus grandi pour devenir un acteur clé dans
illum, nam nemo quod delectus velit [domaine]. Nous avons organisé [nombre]
repellat odio dolorum sapiente soluta, événements et touché plus de [nombre]
aliquam atque praesentium ea placeat ad, personnes grâce à nos initiatives.
neque eveniet adipisci?
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -73,136 +156,62 @@ export default async function About() {
{/* Image Section - Takes 1/3 on large screens */} {/* Image Section - Takes 1/3 on large screens */}
<div className="lg:col-span-1 flex items-center"> <div className="lg:col-span-1 flex items-center">
<img <img
className="w-full h-full object-cover rounded" className="w-full h-full object-cover rounded-lg shadow-md transition-transform duration-300 hover:scale-105"
src={ src={
profileImage?.media?.url ?? profileImage?.media?.url ??
"https://shadcnblocks.com/images/block/placeholder-dark-1.svg" "https://shadcnblocks.com/images/block/placeholder-dark-1.svg"
} }
alt="president profile image" alt="Portrait de Nicolas GORUK, président de l'association"
/> />
</div> </div>
</section>
{/* Locations Section */}
{locations.data && locations.data.length > 0 && (
<section className="py-16 px-6 lg:px-12">
<h2 className="scroll-m-20 border-b pb-3 text-3xl font-semibold tracking-tight text-center">
Retrouvez-nous
</h2>
<div className="mt-12 flex flex-wrap gap-6 justify-center">
{locations.data.map((l: Location) => (
<LocationCard
key={`${l.street}-${l.city}`}
location={l}
/>
))}
</div> </div>
<div className="max-w-2xl mx-auto text-center mb-10 lg:mb-14"> </section>
<h2 className="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"> )}
{/* Pricing Section */}
<section className="py-16 px-6 lg:px-12">
<div className="max-w-3xl mx-auto text-center mb-12">
<h2 className="scroll-m-20 border-b pb-3 text-3xl font-semibold tracking-tight">
Tarifs Tarifs
</h2> </h2>
<p className="mt-1 text-muted-foreground"> <p className="mt-3 text-muted-foreground text-base">
License accessible à partir de 90€. Aide "une aide de Adhésion à partir de [prix].
l'état" possible.
</p> </p>
<p className="mt-1 text-muted-foreground"> <p className="mt-2 text-muted-foreground text-base">
equipement (gants, casque) pris en compte. Prévoir une Équipement (gants, casque) fourni. Prévoir une tenue
tenue sportive adaptée. sportive adaptée.
</p> </p>
</div> </div>
<div className="mt-12 flex flex-col sm:flex-row px-12 justify-center gap-6 lg:items-center"> <div className="mt-12 flex flex-col sm:flex-row justify-center gap-6 lg:items-center">
<Card className="border-primary"> <PricingCard
<CardHeader className="text-center pb-2"> title="Étudiant"
<Badge className="uppercase w-max self-center mb-3"> price={125}
Most popular description="Tarif d'une année pour un étudiant."
</Badge> features={[]}
<CardTitle className="!mb-7">Startup</CardTitle> />
<span className="font-bold text-5xl">£39</span> <PricingCard
</CardHeader> title="Normal"
<CardDescription className="text-center w-11/12 mx-auto"> price={150}
All the basics for starting a new business description="Tarif normal pour n'importe quel individu."
</CardDescription> features={[]}
<CardContent> />
<ul className="mt-7 space-y-2.5 text-sm">
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
2 user
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Plan features
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Product support
</span>
</li>
</ul>
</CardContent>
<CardFooter>
<a href="/contact" className="w-full">
<Button className="w-full">
Prendre contact
</Button>
</a>
</CardFooter>
</Card>
<Card>
<CardHeader className="text-center pb-2">
<CardTitle className="mb-7">Team</CardTitle>
<span className="font-bold text-5xl">£89</span>
</CardHeader>
<CardDescription className="text-center w-11/12 mx-auto">
Everything you need for a growing business
</CardDescription>
<CardContent>
<ul className="mt-7 space-y-2.5 text-sm">
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
5 user
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Plan features
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Product support
</span>
</li>
</ul>
</CardContent>
<CardFooter>{make_contact_div}</CardFooter>
</Card>
<Card>
<CardHeader className="text-center pb-2">
<CardTitle className="mb-7">Enterprise</CardTitle>
<span className="font-bold text-5xl">149</span>
</CardHeader>
<CardDescription className="text-center w-11/12 mx-auto">
Advanced features for scaling your business
</CardDescription>
<CardContent>
<ul className="mt-7 space-y-2.5 text-sm">
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
10 user
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Plan features
</span>
</li>
<li className="flex space-x-2">
<CheckIcon className="flex-shrink-0 mt-0.5 h-4 w-4" />
<span className="text-muted-foreground">
Product support
</span>
</li>
</ul>
</CardContent>
<CardFooter>{make_contact_div}</CardFooter>
</Card>
</div> </div>
</section>
</div> </div>
</>
); );
} }

View File

@@ -13,10 +13,10 @@ import {
Loader2, Loader2,
Camera, Camera,
UserRoundCog, UserRoundCog,
MapPin,
} from "lucide-react"; } from "lucide-react";
import { NavMain } from "@/components/nav-main"; import { NavMain } from "@/components/nav-main";
import { NavProjects } from "@/components/nav-projects";
import { NavUser } from "@/components/nav-user"; import { NavUser } from "@/components/nav-user";
import { TeamSwitcher } from "@/components/team-switcher"; import { TeamSwitcher } from "@/components/team-switcher";
import { import {
@@ -55,6 +55,17 @@ const data = {
}, },
], ],
}, },
{
title: "Adresses",
url: "/dashboard/locations",
icon: MapPin,
items: [
{
title: "Listes des adresses",
url: "/dashboard/locations",
},
],
},
{ {
title: "Planning", title: "Planning",
icon: Calendar, icon: Calendar,

View File

@@ -32,6 +32,20 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useEffect } from "react"; import { useEffect } from "react";
import ICalendarEvent from "@/interfaces/ICalendarEvent";
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/components/ui/command";
import { Textarea } from "@/components/ui/textarea";
import { useApi } from "@/hooks/use-api";
import { Location } from "@/types/types";
import openNavigationApp from "@/lib/openNavigationMap";
import formatLocation from "@/lib/formatLocation";
export const eventFormSchema = z.object({ export const eventFormSchema = z.object({
title: z.string().min(1, "Titre requis"), title: z.string().min(1, "Titre requis"),
@@ -43,6 +57,8 @@ export const eventFormSchema = z.object({
frequency: z.enum(["unique", "quotidien", "hebdomadaire", "mensuel"]), frequency: z.enum(["unique", "quotidien", "hebdomadaire", "mensuel"]),
frequencyEndDate: z.date().optional(), frequencyEndDate: z.date().optional(),
isVisible: z.boolean().default(true), isVisible: z.boolean().default(true),
description: z.string().optional(),
location: z.string().optional(), // Store as a formatted string
}); });
export type EventFormValues = z.infer<typeof eventFormSchema>; export type EventFormValues = z.infer<typeof eventFormSchema>;
@@ -55,13 +71,15 @@ const frequencies = [
]; ];
export const EventForm: React.FC<{ export const EventForm: React.FC<{
event: any; event: ICalendarEvent | Omit<ICalendarEvent, "id">;
setForm: React.Dispatch< setForm: React.Dispatch<
React.SetStateAction< React.SetStateAction<
ReturnType<typeof useForm<EventFormValues>> | undefined ReturnType<typeof useForm<EventFormValues>> | undefined
> >
>; >;
}> = ({ event, setForm }) => { }> = ({ event, setForm }) => {
const locations = useApi<Location[]>("/locations/all");
const _start = new Date(event.start ?? Date.now()); const _start = new Date(event.start ?? Date.now());
const _end = new Date(event.end ?? Date.now()); const _end = new Date(event.end ?? Date.now());
@@ -76,6 +94,8 @@ export const EventForm: React.FC<{
fullDay: event.fullday ?? false, fullDay: event.fullday ?? false,
frequency: "unique", frequency: "unique",
isVisible: event.isVisible ?? true, isVisible: event.isVisible ?? true,
location: event.location,
description: event.description,
}, },
}); });
@@ -106,7 +126,7 @@ export const EventForm: React.FC<{
/> />
<div className="grid grid-cols-[1fr,auto,1fr] items-end gap-2"> <div className="grid grid-cols-[1fr,auto,1fr] items-end gap-2">
{/* Simplified startDate without FormField */} {/* Start Date */}
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel>Début</FormLabel> <FormLabel>Début</FormLabel>
<Popover> <Popover>
@@ -135,25 +155,16 @@ export const EventForm: React.FC<{
align="start" align="start"
> >
<div style={{ pointerEvents: "auto" }}> <div style={{ pointerEvents: "auto" }}>
{/* Force interactivity */}
<Calendar <Calendar
mode="single" mode="single"
selected={form.getValues("startDate")} selected={form.getValues("startDate")}
onSelect={(date) => { onSelect={(date) => {
console.log(
"Start date selected:",
date,
);
if (date) { if (date) {
form.setValue( form.setValue(
"startDate", "startDate",
date, date,
{ shouldValidate: true }, { shouldValidate: true },
); );
console.log(
"Updated startDate:",
form.getValues("startDate"),
);
} }
}} }}
initialFocus initialFocus
@@ -183,7 +194,7 @@ export const EventForm: React.FC<{
<span className="invisible">Until</span> <span className="invisible">Until</span>
{/* Simplified endDate */} {/* End Date */}
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel>Fin</FormLabel> <FormLabel>Fin</FormLabel>
<Popover> <Popover>
@@ -216,18 +227,10 @@ export const EventForm: React.FC<{
mode="single" mode="single"
selected={form.getValues("endDate")} selected={form.getValues("endDate")}
onSelect={(date) => { onSelect={(date) => {
console.log(
"End date selected:",
date,
);
if (date) { if (date) {
form.setValue("endDate", date, { form.setValue("endDate", date, {
shouldValidate: true, shouldValidate: true,
}); });
console.log(
"Updated endDate:",
form.getValues("endDate"),
);
} }
}} }}
initialFocus initialFocus
@@ -286,7 +289,7 @@ export const EventForm: React.FC<{
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Selectionner Fréquence" /> <SelectValue placeholder="Sélectionner Fréquence" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
@@ -343,10 +346,6 @@ export const EventForm: React.FC<{
"frequencyEndDate", "frequencyEndDate",
)} )}
onSelect={(date) => { onSelect={(date) => {
console.log(
"Frequency end date selected:",
date,
);
if (date) { if (date) {
form.setValue( form.setValue(
"frequencyEndDate", "frequencyEndDate",
@@ -356,12 +355,6 @@ export const EventForm: React.FC<{
true, true,
}, },
); );
console.log(
"Updated frequencyEndDate:",
form.getValues(
"frequencyEndDate",
),
);
} }
}} }}
initialFocus initialFocus
@@ -380,7 +373,7 @@ export const EventForm: React.FC<{
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel className="align-sub"> <FormLabel className="align-sub">
Evènement visible ? Évènement visible ?
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Checkbox <Checkbox
@@ -393,6 +386,97 @@ export const EventForm: React.FC<{
</FormItem> </FormItem>
)} )}
/> />
{/* Updated Location Field with Command */}
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Lieu</FormLabel>
<FormControl>
<Command className="rounded-lg border shadow-md">
<CommandInput
placeholder="Rechercher un lieu..."
value={field.value || ""}
onValueChange={(value) =>
field.onChange(value)
}
/>
<CommandList>
{locations.isLoading && (
<CommandEmpty>
Chargement...
</CommandEmpty>
)}
{!locations.isLoading &&
!locations.data?.length && (
<CommandEmpty>
Aucun lieu trouvé.
</CommandEmpty>
)}
{!locations.isLoading &&
locations.data?.length && (
<CommandGroup heading="Suggestions">
{locations.data
.filter((location) =>
formatLocation(
location,
)
.toLowerCase()
.includes(
(
field.value ||
""
).toLowerCase(),
),
)
.map((location) => (
<CommandItem
key={
location.id
}
onSelect={() => {
const formatted =
formatLocation(
location,
);
field.onChange(
formatted,
);
}}
>
{formatLocation(
location,
)}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Ajouter une description"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form> </form>
</Form> </Form>
); );

View File

@@ -0,0 +1,197 @@
"use client";
import * as React from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Trash2, MilestoneIcon, Clock } from "lucide-react";
import { Location } from "@/types/types";
import getOsmEmbedUrl from "@/lib/osmEmbed";
import LocationDialog from "./location-dialog";
import openNavigationApp from "@/lib/openNavigationMap";
import formatLocation from "@/lib/formatLocation";
interface LocationCardProps {
location: Location;
onUpdate?: (location: Location) => void;
onDelete?: (location: Location) => void;
canUpdate?: boolean;
canDelete?: boolean;
}
export const LocationCard: React.FC<LocationCardProps> = ({
location,
onUpdate,
onDelete,
canDelete = false,
canUpdate = false,
}) => {
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
const handleDelete = () => {
onDelete?.(location);
setIsDialogOpen(false);
};
return (
<Card className="w-full max-w-md">
<CardHeader className="flex flex-row justify-between align-middle">
<div>
<CardTitle>{location.street}</CardTitle>
<CardDescription>
{location.city}, {location.postalCode}
</CardDescription>
</div>
<Button
onClick={() => openNavigationApp(formatLocation(location))}
className="m-0"
>
<MilestoneIcon />
</Button>
</CardHeader>
<CardContent className="space-y-4">
{/* OSM Embed Map */}
{location.latitude && location.longitude && (
<div className="h-[200px] w-full rounded-lg border overflow-hidden">
<iframe
width="100%"
height="100%"
src={getOsmEmbedUrl(
location.latitude,
location.longitude,
)}
title="OpenStreetMap Preview"
loading="lazy"
/>
</div>
)}
<div className="flex gap-2 overflow-y-auto">
{location.events?.slice(0, 3).map((event) => (
<div
key={event.id}
className="border rounded-lg p-3 text-sm shadow-sm hover:shadow-md transition-shadow"
>
{/* Event Title */}
<p className="font-semibold truncate">
{event.title || `Event ${event.id}`}
</p>
{/* Event Start Date/Time */}
{event.start && (
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-400 text-xs">
<Clock className="h-3 w-3" />
<span>
{new Date(event.start).toLocaleString(
"fr-FR",
{
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
},
)}
</span>
{event.end && (
<>
<span></span>
<span>
{new Date(
event.end,
).toLocaleString("fr-FR", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
})}
</span>
</>
)}
</div>
)}
{/* Full-day Badge */}
{event.fullday && (
<span className="inline-block mt-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full px-2 py-0.5">
Full Day
</span>
)}
</div>
))}
</div>
{/* Action Buttons */}
<div className="flex justify-between">
<LocationDialog
location={location}
onUpdate={canUpdate ? onUpdate : undefined}
onDelete={canDelete ? onDelete : undefined}
/>
{canDelete && (
<Dialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
>
<DialogTrigger asChild>
<Button
variant="destructive"
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Supprimer
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
Confirmation de suppression
</DialogTitle>
<DialogDescription>
Cela supprimera définitivement cette
adresse:{" "}
<span className="font-semibold">
{location.street}
</span>
, {location.city}, {location.postalCode}
?
<br />
Êtes-vous sûr de vouloir continuer ?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Annuler
</Button>
<Button
variant="destructive"
onClick={handleDelete}
>
Supprimer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,79 @@
import { DialogProps } from "@radix-ui/react-dialog";
import { LocationForm, LocationFormValues } from "./location-form";
import { useState } from "react";
import { UseFormReturn } from "react-hook-form";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { Button } from "../ui/button";
import { Location } from "@/types/types";
const LocationDialog: React.FC<
{
onDelete?: (location: Location) => void;
onUpdate?: (formValues: LocationFormValues) => void;
onAdd?: (formValues: LocationFormValues) => void;
location?: Location;
} & DialogProps
> = ({ open, onOpenChange, onDelete, onUpdate, onAdd, location }) => {
const [form, setForm] = useState<UseFormReturn<LocationFormValues>>();
const submitForm = (event: "add" | "update") => {
const callback = event === "add" ? onAdd : onUpdate;
if (callback) form?.handleSubmit(callback)();
};
if (!(onAdd || onUpdate)) return;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button>
{!location
? "Ajouter une nouvelle adresse"
: "Modifier l'adresse"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{!location ? "Nouvelle adresse" : "Modifier"}
</DialogTitle>
</DialogHeader>
<LocationForm location={location} setForm={setForm} />
<DialogFooter className="flex flex-row justify-end">
{onUpdate && (
<Button
variant="outline"
onClick={() => submitForm("update")}
type="submit"
>
Actualiser
</Button>
)}
{onDelete && (
<Button
variant="destructive"
onClick={() => location && onDelete(location)}
type="submit"
>
Supprimer
</Button>
)}
{onAdd && !onUpdate && !onDelete && (
<Button onClick={() => submitForm("add")} type="submit">
Ajouter
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default LocationDialog;

View File

@@ -0,0 +1,252 @@
"use client";
import * as React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import useDebounce from "@/hooks/use-debounce";
import { OpenStreetMapLocation } from "@/types/types";
import getOsmEmbedUrl from "@/lib/osmEmbed";
import { Location } from "@/types/types";
// Zod schema for validation
const locationFormSchema = z.object({
id: z.number().optional(),
street: z.string().min(1, "Street is required"),
city: z.string().min(1, "City is required"),
postalCode: z.string().min(1, "Postal code is required"),
latitude: z.number().optional(),
longitude: z.number().optional(),
});
export type LocationFormValues = z.infer<typeof locationFormSchema>;
export const LocationForm: React.FC<{
location?: Location;
setForm: React.Dispatch<
React.SetStateAction<
ReturnType<typeof useForm<LocationFormValues>> | undefined
>
>;
}> = ({ location, setForm }) => {
const [osmQuery, setOsmQuery] = React.useState("");
const [suggestions, setSuggestions] = React.useState<
OpenStreetMapLocation[]
>([]);
const [isLoading, setIsLoading] = React.useState(false);
const form = useForm<LocationFormValues>({
resolver: zodResolver(locationFormSchema),
defaultValues: location || {
street: "",
city: "",
postalCode: "",
},
});
React.useEffect(() => {
setForm(form);
}, [form, setForm]);
// Fetch suggestions from OpenStreetMap Nominatim API
const fetchSuggestions = async (query: string) => {
if (!query || query.length < 3) {
setSuggestions([]);
return;
}
setIsLoading(true);
try {
const url = new URL("https://nominatim.openstreetmap.org/search");
url.searchParams.append("q", query);
url.searchParams.append("format", "json");
url.searchParams.append("addressdetails", "1");
url.searchParams.append("limit", "5");
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: OpenStreetMapLocation[] = await response.json();
setSuggestions(data);
} catch (error) {
console.error("Error fetching OSM suggestions:", error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
};
const debouncedFetchSuggestions = useDebounce(fetchSuggestions, 300);
// Handle form submission
const onSubmit = (data: LocationFormValues) => {
console.log("New Location:", data);
// Here you can send the data to your backend (e.g., via API call)
};
const longitude = form.watch("longitude");
const latitude = form.watch("latitude");
// Helper function to construct street from OSM address
const getStreetFromAddress = (
address: OpenStreetMapLocation["address"],
): string => {
const houseNumber = address.house_number || "";
const road = address.road || "";
return (
[houseNumber, road].filter(Boolean).join(" ").trim() ||
"Unknown Street"
);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Autocomplete Input */}
<Command className="rounded-lg border shadow-md h-40">
<CommandInput
placeholder="Écrivez pour trouver une adresse..."
value={osmQuery}
onValueChange={(value) => {
setOsmQuery(value);
debouncedFetchSuggestions(value);
}}
/>
<CommandList>
{isLoading && <CommandEmpty>Loading...</CommandEmpty>}
{!isLoading &&
suggestions.length === 0 &&
osmQuery.length < 1 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{!isLoading && suggestions.length > 0 && (
<CommandGroup heading="Suggestions">
{suggestions.map((suggestion) => (
<CommandItem
key={suggestion.place_id}
onSelect={() => {
const address = suggestion.address;
form.setValue(
"street",
getStreetFromAddress(address),
);
form.setValue(
"city",
address.city ||
address.town ||
address.village ||
"",
);
form.setValue(
"postalCode",
address.postcode || "",
);
form.setValue(
"latitude",
parseFloat(suggestion.lat),
);
form.setValue(
"longitude",
parseFloat(suggestion.lon),
);
}}
>
{suggestion.display_name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
{/* Editable Fields */}
<FormField
control={form.control}
name="street"
render={({ field }) => (
<FormItem>
<FormLabel>Rue</FormLabel>
<FormControl>
<Input
placeholder="Entrer une rue"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>Ville</FormLabel>
<FormControl>
<Input
placeholder="Entrer une ville"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="postalCode"
render={({ field }) => (
<FormItem>
<FormLabel>Code postal</FormLabel>
<FormControl>
<Input
placeholder="Entrer un code postal"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* OSM Embed Map Preview */}
{latitude && longitude && (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-2">
Prévisualisation
</h3>
<div className="h-[300px] w-full rounded-lg border overflow-hidden">
<iframe
width="100%"
height="100%"
src={getOsmEmbedUrl(latitude, longitude)}
title="OpenStreetMap Preview"
/>
</div>
</div>
)}
</form>
</Form>
);
};

View File

@@ -5,7 +5,7 @@ import {
DialogContent, DialogContent,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@radix-ui/react-dialog"; } from "@/components/ui/dialog";
import Image, { ImageProps } from "next/image"; import Image, { ImageProps } from "next/image";
import React, { useState } from "react"; import React, { useState } from "react";

View File

@@ -8,6 +8,7 @@ import { createEventsServicePlugin } from "@schedule-x/events-service";
import { createDragAndDropPlugin } from "@schedule-x/drag-and-drop"; import { createDragAndDropPlugin } from "@schedule-x/drag-and-drop";
import { createResizePlugin } from "@schedule-x/resize"; import { createResizePlugin } from "@schedule-x/resize";
import { createEventRecurrencePlugin } from "@schedule-x/event-recurrence"; import { createEventRecurrencePlugin } from "@schedule-x/event-recurrence";
import { createEventModalPlugin } from "@schedule-x/event-modal";
import { import {
createViewDay, createViewDay,
createViewWeek, createViewWeek,
@@ -50,6 +51,8 @@ const Planning: React.FC<{
if (isConnected && modifiable) { if (isConnected && modifiable) {
plugins.push(createDragAndDropPlugin()); plugins.push(createDragAndDropPlugin());
plugins.push(createResizePlugin()); plugins.push(createResizePlugin());
} else if (isConnected || !isConnected) {
plugins.push(createEventModalPlugin());
} }
const [eventSelected, setEventSelected] = useState<ICalendarEvent | null>( const [eventSelected, setEventSelected] = useState<ICalendarEvent | null>(
null, null,
@@ -60,6 +63,7 @@ const Planning: React.FC<{
const handleEventUpdate = async ( const handleEventUpdate = async (
eventSelected: ICalendarEvent | Omit<ICalendarEvent, "id">, eventSelected: ICalendarEvent | Omit<ICalendarEvent, "id">,
willMutate: boolean = false,
) => { ) => {
if (!isConnected || !modifiable) return; if (!isConnected || !modifiable) return;
const event = { const event = {
@@ -80,7 +84,7 @@ const Planning: React.FC<{
description: res.message, description: res.message,
}); });
} else { } else {
// mutate?.(); willMutate && mutate?.();
} }
} catch (e) { } catch (e) {
if (e instanceof Error) if (e instanceof Error)
@@ -176,6 +180,8 @@ const Planning: React.FC<{
fullday: formValues.fullDay, fullday: formValues.fullDay,
rrule: rrule, rrule: rrule,
isVisible: formValues.isVisible, isVisible: formValues.isVisible,
description: formValues.description,
location: formValues.location,
}; };
const res = await request<undefined>(`/events/new`, { const res = await request<undefined>(`/events/new`, {
method: "POST", method: "POST",
@@ -260,8 +266,10 @@ const Planning: React.FC<{
fullday: formValues.fullDay, fullday: formValues.fullDay,
rrule: rrule, rrule: rrule,
isVisible: formValues.isVisible, isVisible: formValues.isVisible,
description: formValues.description,
location: formValues.location,
}; };
await handleEventUpdate(event); await handleEventUpdate(event, true);
setEventSelected(null); setEventSelected(null);
}} }}
/> />

View File

@@ -0,0 +1,62 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"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",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn(
"mb-1 font-medium leading-none tracking-tight",
className,
)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,31 @@
import React from "react";
export default function useDebounce<T extends (...args: any[]) => void>(
callback: T,
delay: number,
) {
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const debouncedFunction = React.useCallback(
(...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay],
);
// Cleanup on unmount
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedFunction;
}

View File

@@ -1,8 +1,6 @@
import { import { CalendarEventExternal } from "@schedule-x/calendar";
CalendarEventExternal,
} from "@schedule-x/calendar";
export default interface ICalendarEvent extends CalendarEventExternal { export default interface ICalendarEvent extends CalendarEventExternal {
isVisible: boolean, isVisible: boolean;
fullday: boolean, fullday: boolean;
rrule: string rrule: string;
} }

View File

@@ -0,0 +1,8 @@
import { Location } from "@/types/types";
// Helper to format location as a string
const formatLocation = (location: Location): string => {
return `${location.street}, ${location.city}, ${location.postalCode}`;
};
export default formatLocation;

View File

@@ -0,0 +1,28 @@
const openNavigationApp = (
location: string,
latitude?: number | null,
longitude?: number | null,
) => {
if (latitude && longitude) {
const googleMapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`;
const appleMapsUrl = `maps://maps.apple.com/?daddr=${latitude},${longitude}`;
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
if (isIOS) {
window.open(appleMapsUrl, "_blank");
} else {
window.open(googleMapsUrl, "_blank");
}
} else {
const encodedAddress = encodeURIComponent(location);
const googleMapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodedAddress}`;
const appleMapsUrl = `maps://maps.apple.com/?q=${encodedAddress}`;
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
if (isIOS) {
window.open(appleMapsUrl, "_blank");
} else {
window.open(googleMapsUrl, "_blank");
}
}
};
export default openNavigationApp;

11
frontend/lib/osmEmbed.ts Normal file
View File

@@ -0,0 +1,11 @@
// Construct OSM embed URL
const getOsmEmbedUrl = (lat: number, lon: number) => {
const delta = 0.005; // Adjust zoom level
const minLon = lon - delta;
const minLat = lat - delta;
const maxLon = lon + delta;
const maxLat = lat + delta;
return `https://www.openstreetmap.org/export/embed.html?bbox=${minLon},${minLat},${maxLon},${maxLat}&marker=${lat},${lon}&layer=mapnik`;
};
export default getOsmEmbedUrl;

View File

@@ -54,6 +54,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"isomorphic-dompurify": "^2.21.0", "isomorphic-dompurify": "^2.21.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.471.1", "lucide-react": "^0.471.1",
"marked": "^15.0.6", "marked": "^15.0.6",
"next": "15.1.4", "next": "15.1.4",
@@ -64,6 +65,7 @@
"react-dropzone": "^14.3.5", "react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-leaflet": "^5.0.0",
"swr": "^2.3.0", "swr": "^2.3.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -72,6 +74,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@types/leaflet": "^1.9.16",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -2584,6 +2587,17 @@
"integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@remirror/core-constants": { "node_modules/@remirror/core-constants": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
@@ -3175,6 +3189,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3189,6 +3210,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.16",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.16.tgz",
"integrity": "sha512-wzZoyySUxkgMZ0ihJ7IaUIblG8Rdc8AbbZKLneyn+QjYsj5q1QU7TEKYqwTr10BGSzY5LI7tJk9Ifo+mEjdFRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/linkify-it": { "node_modules/@types/linkify-it": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -6393,6 +6424,12 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -7678,6 +7715,20 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.6.3", "version": "2.6.3",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz",

View File

@@ -55,6 +55,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"isomorphic-dompurify": "^2.21.0", "isomorphic-dompurify": "^2.21.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.471.1", "lucide-react": "^0.471.1",
"marked": "^15.0.6", "marked": "^15.0.6",
"next": "15.1.4", "next": "15.1.4",
@@ -65,6 +66,7 @@
"react-dropzone": "^14.3.5", "react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-leaflet": "^5.0.0",
"swr": "^2.3.0", "swr": "^2.3.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -73,6 +75,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@types/leaflet": "^1.9.16",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

View File

@@ -1,3 +1,5 @@
import ICalendarEvent from "@/interfaces/ICalendarEvent";
export interface Permission { export interface Permission {
resource: string; resource: string;
action: string; action: string;
@@ -52,7 +54,6 @@ export interface User {
email: string; email: string;
password?: string; // Optional field, since it's omitted in the JSON password?: string; // Optional field, since it's omitted in the JSON
phone: string; phone: string;
role: Role; // 'admin' or 'user'
createdAt: string; // ISO date string createdAt: string; // ISO date string
updatedAt: string; // ISO date string updatedAt: string; // ISO date string
@@ -66,3 +67,39 @@ export interface ApiResponse<T> {
message: string; message: string;
data?: T; data?: T;
} }
export interface Location {
id?: number;
street: string;
city: string;
postalCode: string;
latitude?: number;
longitude?: number;
events?: ICalendarEvent[];
}
// types/types.ts
export interface OpenStreetMapLocation {
place_id: string; // Unique identifier for the location
licence: string; // Licensing information
osm_type: string; // e.g., "node", "way", "relation"
osm_id: string; // OSM-specific ID
lat: string; // Latitude
lon: string; // Longitude
display_name: string; // Human-readable full address
address: {
house_number?: string; // House number (optional)
road?: string; // Street name (optional)
neighbourhood?: string; // Neighborhood (optional)
suburb?: string; // Suburb (optional)
city?: string; // City (optional)
town?: string; // Town (fallback for city)
village?: string; // Village (fallback for city)
county?: string; // County (optional)
state?: string; // State or region (optional)
postcode?: string; // Postal code (optional)
country?: string; // Country (optional)
country_code?: string; // ISO country code (e.g., "fr")
[key: string]: string | undefined; // Allow for additional fields
};
}