Try this multi page app (1st)
This commit is contained in:
parent
a2de7ce2a0
commit
12543f58eb
14 changed files with 895 additions and 35 deletions
11
ui/external/index.html
vendored
Normal file
11
ui/external/index.html
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="external-root"></div>
|
||||
<script type="module" src="/src/external/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -9,5 +9,19 @@
|
|||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<!-- Xtablo Floating Widget
|
||||
<div id="xtablo-widget-container"></div>
|
||||
<script>
|
||||
(function() {
|
||||
var container = document.getElementById('xtablo-widget-container');
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.src = 'http://localhost:5173/widget/book/arthur-belleville-f6bffb/nouvelle-consultation?buttonVariant=red';
|
||||
iframe.style.cssText = 'position: fixed; bottom: 0; right: 0; width: 100%; height: 100%; border: none; z-index: 999999; background: transparent;';
|
||||
iframe.setAttribute('frameborder', '0');
|
||||
iframe.setAttribute('allow', 'clipboard-write');
|
||||
|
||||
container.appendChild(iframe);
|
||||
})();
|
||||
</script> -->
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,8 @@ import { ThemeProvider } from "@ui/contexts/ThemeContext";
|
|||
import { routes } from "@ui/lib/routes";
|
||||
import { DatadogRumProvider } from "@ui/providers/DatadogRumProvider";
|
||||
import { UserStoreProvider } from "@ui/providers/UserStoreProvider";
|
||||
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
|
||||
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
|
||||
|
||||
// Register all Community features
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const AppRoutes = () => {
|
||||
const element = useRoutes(routes);
|
||||
return element;
|
||||
|
|
|
|||
|
|
@ -16,11 +16,14 @@ import {
|
|||
} from "@ui/components/ui/select";
|
||||
import { CopyButton } from "@ui/components/ui/clipboard";
|
||||
import { useState } from "react";
|
||||
import { TypographyMuted } from "@ui/components/ui/typography";
|
||||
import { TypographyMuted, TypographyP } from "@ui/components/ui/typography";
|
||||
|
||||
type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange" | "red";
|
||||
|
||||
type EmbedType = "full" | "floating";
|
||||
|
||||
interface EmbedConfig {
|
||||
embedType: EmbedType;
|
||||
backgroundVariant: ColorVariant;
|
||||
buttonVariant: ColorVariant;
|
||||
}
|
||||
|
|
@ -29,25 +32,55 @@ interface EmbedConfigModalProps {
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
baseEmbedUrl: string;
|
||||
baseFloatingUrl: string;
|
||||
}
|
||||
|
||||
export function EmbedConfigModal({ isOpen, onClose, baseEmbedUrl }: EmbedConfigModalProps) {
|
||||
export function EmbedConfigModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
baseEmbedUrl,
|
||||
baseFloatingUrl,
|
||||
}: EmbedConfigModalProps) {
|
||||
const [embedConfig, setEmbedConfig] = useState<EmbedConfig>({
|
||||
embedType: "full",
|
||||
backgroundVariant: "purple",
|
||||
buttonVariant: "purple",
|
||||
});
|
||||
|
||||
const getEmbedUrl = () => {
|
||||
const baseUrl = embedConfig.embedType === "full" ? baseEmbedUrl : baseFloatingUrl;
|
||||
const params = new URLSearchParams({
|
||||
backgroundVariant: embedConfig.backgroundVariant,
|
||||
buttonVariant: embedConfig.buttonVariant,
|
||||
});
|
||||
return `${baseEmbedUrl}?${params.toString()}`;
|
||||
|
||||
// Only add backgroundVariant for full embed
|
||||
if (embedConfig.embedType === "full") {
|
||||
params.set("backgroundVariant", embedConfig.backgroundVariant);
|
||||
}
|
||||
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const generateEmbedCode = () => {
|
||||
const embedUrl = getEmbedUrl();
|
||||
|
||||
if (embedConfig.embedType === "floating") {
|
||||
return `<!-- Xtablo Floating Widget -->
|
||||
<div id="xtablo-widget-container"></div>
|
||||
<script>
|
||||
(function() {
|
||||
var container = document.getElementById('xtablo-widget-container');
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.src = '${embedUrl}';
|
||||
iframe.style.cssText = 'position: fixed; bottom: 0; right: 0; width: 100%; height: 100%; border: none; z-index: 999999; background: transparent;';
|
||||
iframe.setAttribute('frameborder', '0');
|
||||
iframe.setAttribute('allow', 'clipboard-write');
|
||||
|
||||
container.appendChild(iframe);
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
return `<iframe
|
||||
src="${embedUrl}"
|
||||
width="1130"
|
||||
|
|
@ -78,29 +111,57 @@ export function EmbedConfigModal({ isOpen, onClose, baseEmbedUrl }: EmbedConfigM
|
|||
{/* Configuration Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Couleur de fond</Label>
|
||||
<Label>Type d'intégration</Label>
|
||||
<Select
|
||||
value={embedConfig.backgroundVariant}
|
||||
value={embedConfig.embedType}
|
||||
onValueChange={(value) =>
|
||||
setEmbedConfig({ ...embedConfig, backgroundVariant: value as ColorVariant })
|
||||
setEmbedConfig({ ...embedConfig, embedType: value as EmbedType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{colorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-4 h-4 rounded ${option.color}`}></div>
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="full">
|
||||
<div className="flex flex-col items-start">
|
||||
<TypographyP className="font-medium">Page complète</TypographyP>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="floating">
|
||||
<div className="flex flex-col items-start">
|
||||
<TypographyP className="font-medium">Widget flottant</TypographyP>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{embedConfig.embedType === "full" && (
|
||||
<div className="space-y-2">
|
||||
<Label>Couleur de fond</Label>
|
||||
<Select
|
||||
value={embedConfig.backgroundVariant}
|
||||
onValueChange={(value) =>
|
||||
setEmbedConfig({ ...embedConfig, backgroundVariant: value as ColorVariant })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{colorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-4 h-4 rounded ${option.color}`}></div>
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Couleur des boutons</Label>
|
||||
<Select
|
||||
|
|
@ -150,7 +211,9 @@ export function EmbedConfigModal({ isOpen, onClose, baseEmbedUrl }: EmbedConfigM
|
|||
<div className="space-y-2 min-w-0">
|
||||
<Label>Code d'intégration</Label>
|
||||
<TypographyMuted className="text-xs">
|
||||
Copiez ce code pour intégrer le formulaire de réservation sur votre site web
|
||||
{embedConfig.embedType === "floating"
|
||||
? "Copiez ce code pour ajouter le widget flottant sur votre site web"
|
||||
: "Copiez ce code pour intégrer le formulaire de réservation sur votre site web"}
|
||||
</TypographyMuted>
|
||||
<div className="relative min-w-0">
|
||||
<div className="overflow-auto max-w-full">
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ export function EventTypeCard({
|
|||
const user = useUser();
|
||||
const [isEmbedModalOpen, setIsEmbedModalOpen] = useState(false);
|
||||
|
||||
const getPublicLink = (standardName: string | null, isEmbed: boolean = false) => {
|
||||
const getPublicLink = (
|
||||
standardName: string | null,
|
||||
type: "normal" | "embed" | "floating" = "normal"
|
||||
) => {
|
||||
// Sanitize user name for URL (replace spaces with hyphens, lowercase, remove special chars)
|
||||
const sanitizedUserName = user.name
|
||||
?.toLowerCase()
|
||||
|
|
@ -41,9 +44,12 @@ export function EventTypeCard({
|
|||
const shortUserId = user.id.substring(0, 6);
|
||||
// Construct the public booking URL
|
||||
const baseUrl = window.location.origin;
|
||||
if (isEmbed) {
|
||||
if (type === "embed") {
|
||||
return `${baseUrl}/embed/book/${sanitizedUserName}-${shortUserId}/${standardName}`;
|
||||
}
|
||||
if (type === "floating") {
|
||||
return `${baseUrl}/widget/book/${sanitizedUserName}-${shortUserId}/${standardName}`;
|
||||
}
|
||||
return `${baseUrl}/book/${sanitizedUserName}-${shortUserId}/${standardName}`;
|
||||
};
|
||||
|
||||
|
|
@ -148,7 +154,8 @@ export function EventTypeCard({
|
|||
<EmbedConfigModal
|
||||
isOpen={isEmbedModalOpen}
|
||||
onClose={() => setIsEmbedModalOpen(false)}
|
||||
baseEmbedUrl={getPublicLink(eventType.standardName ?? null, true)}
|
||||
baseEmbedUrl={getPublicLink(eventType.standardName ?? null, "embed")}
|
||||
baseFloatingUrl={getPublicLink(eventType.standardName ?? null, "floating")}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
688
ui/src/external/FloatingBookingWidget.tsx
vendored
Normal file
688
ui/src/external/FloatingBookingWidget.tsx
vendored
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
import { CustomModal } from "@ui/components/CustomModal";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { Button } from "@ui/components/ui/button";
|
||||
import { FieldError } from "@ui/components/ui/field";
|
||||
import { Input } from "@ui/components/ui/input";
|
||||
import { Label } from "@ui/components/ui/label";
|
||||
import { Text, TypographyH4, TypographyMuted } from "@ui/components/ui/typography";
|
||||
import { useSession } from "@ui/contexts/SessionContext";
|
||||
import { useSignUpWithoutPassword } from "@ui/hooks/auth";
|
||||
import { TimeSlot, usePublicSlots } from "@ui/hooks/public";
|
||||
import { useCreateTabloWithOwner } from "@ui/hooks/tablos";
|
||||
import { useMaybeUser } from "@ui/providers/UserStoreProvider";
|
||||
import { EventInsertInTablo } from "@ui/types/events.types";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
MapPinIcon,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type ColorVariant = "black" | "white" | "blue" | "purple" | "green" | "orange" | "red";
|
||||
|
||||
// Color scheme configurations
|
||||
const buttonColors = {
|
||||
black: {
|
||||
floating: "bg-gray-900 hover:bg-gray-800 text-white",
|
||||
selected: "bg-gray-900 dark:bg-white text-white dark:text-gray-900",
|
||||
ring: "ring-gray-500/50",
|
||||
todayBorder: "border-gray-500/30",
|
||||
hoverBorder: "hover:border-gray-500/50",
|
||||
slotHover:
|
||||
"hover:bg-gray-900 dark:hover:bg-white hover:text-white dark:hover:text-gray-900 hover:border-gray-500/50",
|
||||
modalBorder: "border-gray-500/20",
|
||||
modalIcon: "text-gray-600 dark:text-gray-400",
|
||||
},
|
||||
white: {
|
||||
floating: "bg-white hover:bg-gray-50 text-gray-900 border border-gray-300",
|
||||
selected: "bg-white dark:bg-gray-100 text-gray-900 dark:text-gray-900",
|
||||
ring: "ring-gray-300/50",
|
||||
todayBorder: "border-gray-300/30",
|
||||
hoverBorder: "hover:border-gray-300/50",
|
||||
slotHover:
|
||||
"hover:bg-white dark:hover:bg-gray-100 hover:text-gray-900 dark:hover:text-gray-900 hover:border-gray-300/50",
|
||||
modalBorder: "border-gray-300/20",
|
||||
modalIcon: "text-gray-600 dark:text-gray-500",
|
||||
},
|
||||
blue: {
|
||||
floating: "bg-blue-600 hover:bg-blue-700 text-white",
|
||||
selected: "bg-blue-600 dark:bg-blue-500 text-white dark:text-white",
|
||||
ring: "ring-blue-500/50",
|
||||
todayBorder: "border-blue-500/30",
|
||||
hoverBorder: "hover:border-blue-500/50",
|
||||
slotHover:
|
||||
"hover:bg-blue-600 dark:hover:bg-blue-500 hover:text-white dark:hover:text-white hover:border-blue-500/50",
|
||||
modalBorder: "border-blue-500/20",
|
||||
modalIcon: "text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
purple: {
|
||||
floating: "bg-purple-600 hover:bg-purple-700 text-white",
|
||||
selected: "bg-purple-600 dark:bg-purple-500 text-white dark:text-white",
|
||||
ring: "ring-purple-500/50",
|
||||
todayBorder: "border-purple-500/30",
|
||||
hoverBorder: "hover:border-purple-500/50",
|
||||
slotHover:
|
||||
"hover:bg-purple-600 dark:hover:bg-purple-500 hover:text-white dark:hover:text-white hover:border-purple-500/50",
|
||||
modalBorder: "border-purple-500/20",
|
||||
modalIcon: "text-purple-600 dark:text-purple-400",
|
||||
},
|
||||
green: {
|
||||
floating: "bg-green-600 hover:bg-green-700 text-white",
|
||||
selected: "bg-green-600 dark:bg-green-500 text-white dark:text-white",
|
||||
ring: "ring-green-500/50",
|
||||
todayBorder: "border-green-500/30",
|
||||
hoverBorder: "hover:border-green-500/50",
|
||||
slotHover:
|
||||
"hover:bg-green-600 dark:hover:bg-green-500 hover:text-white dark:hover:text-white hover:border-green-500/50",
|
||||
modalBorder: "border-green-500/20",
|
||||
modalIcon: "text-green-600 dark:text-green-400",
|
||||
},
|
||||
orange: {
|
||||
floating: "bg-orange-600 hover:bg-orange-700 text-white",
|
||||
selected: "bg-orange-600 dark:bg-orange-500 text-white dark:text-white",
|
||||
ring: "ring-orange-500/50",
|
||||
todayBorder: "border-orange-500/30",
|
||||
hoverBorder: "hover:border-orange-500/50",
|
||||
slotHover:
|
||||
"hover:bg-orange-600 dark:hover:bg-orange-500 hover:text-white dark:hover:text-white hover:border-orange-500/50",
|
||||
modalBorder: "border-orange-500/20",
|
||||
modalIcon: "text-orange-600 dark:text-orange-400",
|
||||
},
|
||||
red: {
|
||||
floating: "bg-red-600 hover:bg-red-700 text-white",
|
||||
selected: "bg-red-600 dark:bg-red-500 text-white dark:text-white",
|
||||
ring: "ring-red-500/50",
|
||||
todayBorder: "border-red-500/30",
|
||||
hoverBorder: "hover:border-red-500/50",
|
||||
slotHover:
|
||||
"hover:bg-red-600 dark:hover:bg-red-500 hover:text-white dark:hover:text-white hover:border-red-500/50",
|
||||
modalBorder: "border-red-500/20",
|
||||
modalIcon: "text-red-600 dark:text-red-400",
|
||||
},
|
||||
};
|
||||
|
||||
export function FloatingBookingWidget() {
|
||||
const { user_info, event_type_standard_name } = useParams<{
|
||||
user_info: string;
|
||||
event_type_standard_name: string;
|
||||
}>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { mutateAsync: signUpWithoutPassword } = useSignUpWithoutPassword();
|
||||
const { session } = useSession();
|
||||
const user = useMaybeUser();
|
||||
const shortUserId = user_info?.substring(user_info.lastIndexOf("-") + 1);
|
||||
|
||||
// Get variants from URL params with fallback to purple
|
||||
const buttonVariant = (searchParams.get("buttonVariant") as ColorVariant) || "purple";
|
||||
|
||||
// Get color schemes based on variants
|
||||
const btnColors = buttonColors[buttonVariant];
|
||||
|
||||
const { data: publicSlots, isLoading: isLoadingSlots } = usePublicSlots(
|
||||
shortUserId || "",
|
||||
event_type_standard_name || ""
|
||||
);
|
||||
|
||||
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner();
|
||||
|
||||
const userProfile = publicSlots?.user;
|
||||
const eventType = publicSlots?.eventType;
|
||||
const slotsData = publicSlots?.slots || {};
|
||||
|
||||
// Widget state
|
||||
const [isWidgetOpen, setIsWidgetOpen] = useState(false);
|
||||
|
||||
// Calendar state
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
|
||||
// Modal state (for booking confirmation)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedSlot, setSelectedSlot] = useState<{
|
||||
date: Date;
|
||||
slot: TimeSlot;
|
||||
} | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
email: "",
|
||||
name: "",
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState({
|
||||
email: "",
|
||||
name: "",
|
||||
});
|
||||
|
||||
// Helper function to convert date to CET timezone string (YYYY-MM-DD)
|
||||
const formatDateToCET = (date: Date): string => {
|
||||
return date.toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
||||
};
|
||||
|
||||
// Helper function to get current date in CET timezone
|
||||
const getCurrentDateInCET = (): Date => {
|
||||
const now = new Date();
|
||||
const cetTime = new Date(now.toLocaleString("en-US", { timeZone: "Europe/Paris" }));
|
||||
return cetTime;
|
||||
};
|
||||
|
||||
// Get available time slots for a specific date
|
||||
const getAvailableSlots = (date: Date): TimeSlot[] => {
|
||||
const dateStr = formatDateToCET(date);
|
||||
return slotsData[dateStr]?.filter((slot) => slot.available) || [];
|
||||
};
|
||||
|
||||
// Check if a date has any available slots
|
||||
const hasAvailableSlots = (date: Date): boolean => {
|
||||
const dateStr = formatDateToCET(date);
|
||||
return slotsData[dateStr]?.some((slot) => slot.available) || false;
|
||||
};
|
||||
|
||||
// Calendar helper functions
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
|
||||
// Create first day of month and get its day of week in CET
|
||||
const firstDayStr = `${year}-${String(month + 1).padStart(2, "0")}-01`;
|
||||
const firstDay = new Date(`${firstDayStr}T12:00:00`);
|
||||
const firstDayOfWeekInCET = new Date(
|
||||
firstDay.toLocaleString("en-US", { timeZone: "Europe/Paris" })
|
||||
).getDay();
|
||||
|
||||
// Adjust for Monday as first day of week
|
||||
const mondayStartingDay = firstDayOfWeekInCET === 0 ? 6 : firstDayOfWeekInCET - 1;
|
||||
|
||||
// Get number of days in month
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
|
||||
const days = [];
|
||||
|
||||
// Add empty cells for days before the first day of the month
|
||||
for (let i = 0; i < mondayStartingDay; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
|
||||
// Add all days of the month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}`;
|
||||
days.push(new Date(`${dayStr}T12:00:00`));
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const navigateMonth = (direction: "prev" | "next") => {
|
||||
setCurrentDate((prev) => {
|
||||
const newDate = new Date(prev);
|
||||
if (direction === "prev") {
|
||||
newDate.setMonth(prev.getMonth() - 1);
|
||||
} else {
|
||||
newDate.setMonth(prev.getMonth() + 1);
|
||||
}
|
||||
return newDate;
|
||||
});
|
||||
};
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
const todayInCET = getCurrentDateInCET();
|
||||
const todayStr = formatDateToCET(todayInCET);
|
||||
const dateStr = formatDateToCET(date);
|
||||
return dateStr === todayStr;
|
||||
};
|
||||
|
||||
const isPastDate = (date: Date) => {
|
||||
const todayInCET = getCurrentDateInCET();
|
||||
const todayStr = formatDateToCET(todayInCET);
|
||||
const dateStr = formatDateToCET(date);
|
||||
return dateStr < todayStr;
|
||||
};
|
||||
|
||||
const formatMonthYear = (date: Date) => {
|
||||
return date.toLocaleDateString("fr-FR", { month: "long", year: "numeric" });
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
if (minutes < 60) {
|
||||
return `${minutes} min`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (remainingMinutes === 0) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
return `${hours}h ${remainingMinutes}min`;
|
||||
};
|
||||
|
||||
// Modal and form handlers
|
||||
const handleSlotClick = (date: Date, slot: TimeSlot) => {
|
||||
setSelectedSlot({ date, slot });
|
||||
setIsModalOpen(true);
|
||||
setFormData({ email: "", name: "" });
|
||||
setFormErrors({ email: "", name: "" });
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedSlot(null);
|
||||
setFormData({ email: "", name: "" });
|
||||
setFormErrors({ email: "", name: "" });
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const errors = { email: "", name: "" };
|
||||
let isValid = true;
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
errors.email = "L'adresse email est requise";
|
||||
isValid = false;
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = "Veuillez entrer une adresse email valide";
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
errors.name = "Le nom est requis";
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
// Calculate end time based on start time and duration
|
||||
const calculateEndTime = (startTime: string, durationMinutes: number): string => {
|
||||
if (!startTime) return "";
|
||||
|
||||
const [hours, minutes] = startTime.split(":").map(Number);
|
||||
const startDate = new Date();
|
||||
startDate.setHours(hours, minutes, 0, 0);
|
||||
|
||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||
|
||||
return endDate.toTimeString().slice(0, 5); // Format as HH:MM
|
||||
};
|
||||
|
||||
const handleSubmitIfNotLoggedIn = async () => {
|
||||
if (validateForm()) {
|
||||
const { session: sessionFromSignUp } = await signUpWithoutPassword({
|
||||
email: formData.email,
|
||||
name: formData.name,
|
||||
});
|
||||
|
||||
const startTime = selectedSlot?.slot.time || "";
|
||||
const duration = eventType?.duration || 60; // duration in minutes
|
||||
const endTime = calculateEndTime(startTime, duration);
|
||||
|
||||
await createTabloWithOwner({
|
||||
name: eventType?.name || "",
|
||||
status: "todo",
|
||||
owner_short_id: shortUserId || "",
|
||||
event: {
|
||||
description: eventType?.description || "",
|
||||
end_time: endTime || "",
|
||||
start_date: selectedSlot?.slot.date || "",
|
||||
start_time: selectedSlot?.slot.time || "",
|
||||
title: eventType?.name || "",
|
||||
} as EventInsertInTablo,
|
||||
access_token: sessionFromSignUp?.access_token || "",
|
||||
});
|
||||
|
||||
handleCloseModal();
|
||||
setIsWidgetOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitIfLoggedIn = async () => {
|
||||
if (user) {
|
||||
const startTime = selectedSlot?.slot.time || "";
|
||||
const duration = eventType?.duration || 60; // duration in minutes
|
||||
const endTime = calculateEndTime(startTime, duration);
|
||||
|
||||
await createTabloWithOwner({
|
||||
name: eventType?.name || "",
|
||||
status: "todo",
|
||||
owner_short_id: shortUserId || "",
|
||||
event: {
|
||||
description: eventType?.description || "",
|
||||
end_time: endTime || "",
|
||||
start_date: selectedSlot?.slot.date || "",
|
||||
start_time: selectedSlot?.slot.time || "",
|
||||
title: eventType?.name || "",
|
||||
} as EventInsertInTablo,
|
||||
access_token: session?.access_token || "",
|
||||
});
|
||||
|
||||
handleCloseModal();
|
||||
setIsWidgetOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingSlots) {
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="fixed bottom-6 right-6 z-50 pointer-events-auto">
|
||||
<Button
|
||||
size="lg"
|
||||
className={twMerge(
|
||||
"rounded-full h-14 w-14 shadow-lg transition-all duration-200",
|
||||
btnColors.floating
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
<LoadingSpinner />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
{/* Floating Button */}
|
||||
<div className="fixed bottom-6 right-6 z-50 pointer-events-auto">
|
||||
<Button
|
||||
size="lg"
|
||||
className={twMerge(
|
||||
"rounded-full h-14 w-14 shadow-lg hover:shadow-xl transition-all duration-200",
|
||||
btnColors.floating,
|
||||
isWidgetOpen && "scale-0 opacity-0"
|
||||
)}
|
||||
onClick={() => setIsWidgetOpen(true)}
|
||||
>
|
||||
<CalendarIcon className="w-6 h-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Floating Widget Popup */}
|
||||
{isWidgetOpen && (
|
||||
<div className="fixed bottom-6 right-6 z-50 w-[450px] max-h-[650px] bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden animate-in slide-in-from-bottom-4 duration-300 pointer-events-auto">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{(userProfile as { name: string; avatar_url?: string })?.avatar_url ? (
|
||||
<img
|
||||
src={(userProfile as { name: string; avatar_url?: string }).avatar_url}
|
||||
alt={userProfile?.name || "Profile"}
|
||||
className="w-12 h-12 rounded-full object-cover border-2 border-gray-200 dark:border-gray-600 flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||
<UserIcon className="w-6 h-6 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white text-sm truncate">
|
||||
{eventType?.name || "Type d'événement"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{userProfile?.name || "Professionnel"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
onClick={() => {
|
||||
setIsWidgetOpen(false);
|
||||
setSelectedDate(null);
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Event Info */}
|
||||
{(eventType?.duration || eventType?.location) && (
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{eventType?.duration && (
|
||||
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
|
||||
<ClockIcon className="w-3.5 h-3.5" />
|
||||
<span>{formatDuration(eventType.duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
{eventType?.location && (
|
||||
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-400">
|
||||
<MapPinIcon className="w-3.5 h-3.5" />
|
||||
<span className="truncate max-w-[200px]">{eventType.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar and Slots */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* Calendar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<TypographyH4 className="font-semibold text-gray-900 dark:text-white capitalize text-sm">
|
||||
{formatMonthYear(currentDate)}
|
||||
</TypographyH4>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigateMonth("prev")}
|
||||
className="h-7 w-7 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigateMonth("next")}
|
||||
className="h-7 w-7 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{["L", "M", "M", "J", "V", "S", "D"].map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-1 text-center text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{getDaysInMonth(currentDate).map((date, index) => (
|
||||
<div key={index} className="aspect-square">
|
||||
{date ? (
|
||||
<button
|
||||
onClick={() =>
|
||||
!isPastDate(date) && hasAvailableSlots(date) && setSelectedDate(date)
|
||||
}
|
||||
disabled={isPastDate(date) || !hasAvailableSlots(date)}
|
||||
className={twMerge(
|
||||
"w-full h-full flex items-center justify-center text-xs rounded-lg transition-colors",
|
||||
isPastDate(date)
|
||||
? "text-gray-300 dark:text-gray-600 cursor-not-allowed"
|
||||
: selectedDate?.toDateString() === date.toDateString()
|
||||
? `${btnColors.selected} font-semibold shadow-md ring-2 ${btnColors.ring}`
|
||||
: isToday(date)
|
||||
? `bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold border ${btnColors.todayBorder}`
|
||||
: hasAvailableSlots(date)
|
||||
? `text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 ${btnColors.hoverBorder} border border-gray-200 dark:border-gray-600`
|
||||
: "text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{date.getDate()}
|
||||
</button>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Slots */}
|
||||
{selectedDate && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<TypographyH4 className="font-semibold text-gray-900 dark:text-white mb-2 text-sm">
|
||||
Créneaux disponibles
|
||||
</TypographyH4>
|
||||
<TypographyMuted className="text-xs font-normal text-gray-500 dark:text-gray-400 mb-3">
|
||||
{selectedDate.toLocaleDateString("fr-FR", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</TypographyMuted>
|
||||
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto pr-1">
|
||||
{getAvailableSlots(selectedDate).map((slot, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"w-full justify-center text-sm py-2 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600 transition-all",
|
||||
btnColors.slotHover
|
||||
)}
|
||||
onClick={() => handleSlotClick(selectedDate, slot)}
|
||||
>
|
||||
{slot.time}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{getAvailableSlots(selectedDate).length === 0 && (
|
||||
<div className="text-center py-4">
|
||||
<Text className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Aucun créneau disponible
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedDate && (
|
||||
<div className="text-center py-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<CalendarIcon className="w-8 h-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
|
||||
<Text className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Sélectionnez une date pour voir les créneaux disponibles
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
<TypographyMuted className="text-xs text-center text-gray-500">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://www.xtablo.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
XTablo
|
||||
</a>
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Modal */}
|
||||
<CustomModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
title={user ? "Confirmer la réservation" : "Créer un compte pour réserver"}
|
||||
width="md"
|
||||
>
|
||||
{selectedSlot && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"mb-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border",
|
||||
btnColors.modalBorder
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-100">
|
||||
<CalendarIcon className={twMerge("w-4 h-4", btnColors.modalIcon)} />
|
||||
<Text className="font-medium">
|
||||
{selectedSlot.date.toLocaleDateString("fr-FR", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-900 dark:text-gray-100 mt-1">
|
||||
<ClockIcon className={twMerge("w-4 h-4", btnColors.modalIcon)} />
|
||||
<Text className="font-medium">{selectedSlot.slot.time}</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
Nom complet <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Votre nom complet"
|
||||
value={user?.name || formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
disabled={!!user}
|
||||
/>
|
||||
{formErrors.name && <FieldError errors={[{ message: formErrors.name }]} />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
Adresse email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="votre@email.com"
|
||||
value={user?.email || formData.email}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, email: e.target.value }))}
|
||||
disabled={!!user}
|
||||
/>
|
||||
{formErrors.email && <FieldError errors={[{ message: formErrors.email }]} />}
|
||||
</div>
|
||||
|
||||
{!user && (
|
||||
<div className="pt-2">
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Un compte sera créé avec ces informations pour gérer votre réservation.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button variant="outline" onClick={handleCloseModal}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={user ? handleSubmitIfLoggedIn : handleSubmitIfNotLoggedIn}
|
||||
>
|
||||
{user ? "Confirmer la réservation" : "Créer le compte et réserver"}
|
||||
</Button>
|
||||
</div>
|
||||
</CustomModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
ui/src/external/main.tsx
vendored
Normal file
30
ui/src/external/main.tsx
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { queryClient } from "@ui/lib/api";
|
||||
import { externalRoutes } from "./routes";
|
||||
import { ThemeProvider } from "src/contexts/ThemeContext";
|
||||
import { Toaster } from "src/components/ui/sonner";
|
||||
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
|
||||
|
||||
import "../main.css";
|
||||
|
||||
const ExternalAppRoutes = () => {
|
||||
const element = useRoutes(externalRoutes);
|
||||
return element;
|
||||
};
|
||||
|
||||
createRoot(document.getElementById("external-root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<Toaster />
|
||||
<Router>
|
||||
<div className="min-h-screen bg-background">
|
||||
<ExternalAppRoutes />
|
||||
</div>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
19
ui/src/external/routes.tsx
vendored
Normal file
19
ui/src/external/routes.tsx
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { EmbeddedBookingPage } from "@external/EmbeddedBookingPage";
|
||||
import { FloatingBookingWidget } from "@external/FloatingBookingWidget";
|
||||
import { RouteObject } from "react-router-dom";
|
||||
|
||||
export const externalRoutes: RouteObject[] = [
|
||||
{
|
||||
path: "/external/",
|
||||
children: [
|
||||
{
|
||||
path: "embed/book/:user_info/:event_type_standard_name",
|
||||
element: <EmbeddedBookingPage />,
|
||||
},
|
||||
{
|
||||
path: "widget/book/:user_info/:event_type_standard_name",
|
||||
element: <FloatingBookingWidget />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -7,7 +7,7 @@ export const api = axios.create({
|
|||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 2000,
|
||||
timeout: 4000,
|
||||
});
|
||||
|
||||
// Create React Query client with default options
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { LoginPage } from "@ui/pages/login";
|
|||
import { NotFoundPage } from "@ui/pages/NotFoundPage";
|
||||
import { OAuthSigninPage } from "@ui/pages/oauth-signin";
|
||||
import { PublicBookingPage } from "@ui/pages/PublicBookingPage";
|
||||
import { EmbeddedBookingPage } from "@ui/pages/EmbeddedBookingPage";
|
||||
import { PlanningPage } from "@ui/pages/planning";
|
||||
import { ResetPasswordPage } from "@ui/pages/reset-password";
|
||||
import SettingsPage from "@ui/pages/settings";
|
||||
|
|
@ -128,11 +127,6 @@ export const routes: RouteObject[] = [
|
|||
path: "/book/:user_info/:event_type_standard_name",
|
||||
element: <PublicBookingPage />,
|
||||
},
|
||||
// Embedded booking route (for iframe integration)
|
||||
{
|
||||
path: "/embed/book/:user_info/:event_type_standard_name",
|
||||
element: <EmbeddedBookingPage />,
|
||||
},
|
||||
// Authentication pages (redirected to "/" if user is authenticated)
|
||||
{
|
||||
path: "/",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@
|
|||
|
||||
"paths": {
|
||||
"*": ["./*"],
|
||||
"@ui/*": ["./src/*"]
|
||||
"@ui/*": ["./src/*"],
|
||||
"@external/*": ["./src/external/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
"types": ["./worker-configuration.d.ts"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@ui/*": ["./src/*"]
|
||||
"@ui/*": ["./src/*"],
|
||||
"@external/*": ["src/external/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,43 @@
|
|||
/// <reference types="vitest" />
|
||||
|
||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
// import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { dirname, resolve } from "path";
|
||||
import { visualizer } from "rollup-plugin-visualizer";
|
||||
import { defineConfig, type PluginOption } from "vite";
|
||||
import { fileURLToPath } from "url";
|
||||
import { defineConfig, ViteDevServer, type PluginOption } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), visualizer() as PluginOption, tailwindcss(), tsconfigPaths(), cloudflare()],
|
||||
plugins: [
|
||||
react(),
|
||||
visualizer() as PluginOption,
|
||||
tailwindcss(),
|
||||
tsconfigPaths(),
|
||||
{
|
||||
name: "configure-server",
|
||||
configureServer(server: ViteDevServer) {
|
||||
return () => {
|
||||
server.middlewares.use(async (req, _res, next) => {
|
||||
for (const appName of Object.keys(
|
||||
server.config.build.rollupOptions.input as Record<string, string>
|
||||
)) {
|
||||
if (req?.originalUrl?.startsWith(`/${appName}`)) {
|
||||
req.url = `/${appName}/index.html`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
// cloudflare(),
|
||||
],
|
||||
server: {
|
||||
cors: false,
|
||||
},
|
||||
|
|
@ -18,4 +46,12 @@ export default defineConfig({
|
|||
environment: "jsdom",
|
||||
setupFiles: "./src/setupTests.ts",
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, "index.html"),
|
||||
external: resolve(__dirname, "external/index.html"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue