This commit is contained in:
+16
-120
@@ -1,138 +1,34 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
#push:
|
||||
# branches: [main]
|
||||
#pull_request:
|
||||
# branches: [main]
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: test
|
||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/test?schema=public"
|
||||
NEXTAUTH_SECRET: "test-secret-for-ci"
|
||||
NEXTAUTH_URL: "http://localhost:3000"
|
||||
AUTH_SECRET: "test-secret-for-ci"
|
||||
AUTH_URL: "http://localhost:3000"
|
||||
RESEND_APIKEY: "any-api-key-for-ci"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: ${{ env.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ env.POSTGRES_DB }}
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
runs-on: easynode-debian
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: npx prisma generate
|
||||
|
||||
- name: Run database migrations
|
||||
run: npx prisma db push
|
||||
|
||||
run: npm i
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Build application
|
||||
run: npm run build
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@v2
|
||||
with:
|
||||
version: 2.4.4
|
||||
|
||||
- name: Run Biome
|
||||
run: biome ci .
|
||||
|
||||
e2e:
|
||||
name: E2E Tests (Playwright)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, build]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: ${{ env.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ env.POSTGRES_DB }}
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: npx prisma generate
|
||||
|
||||
- name: Run database migrations
|
||||
run: npx prisma db push
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npm test
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
name: Deploy to Vercel
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
|
||||
@@ -43,7 +43,9 @@ export async function POST(
|
||||
// Check weekly limit for target week, excluding the booking being moved
|
||||
if (booking.eventType.maxBookingsPerWeek !== null) {
|
||||
const weekStart = new Date(newStartTime);
|
||||
weekStart.setUTCDate(newStartTime.getUTCDate() - newStartTime.getUTCDay());
|
||||
weekStart.setUTCDate(
|
||||
newStartTime.getUTCDate() - newStartTime.getUTCDay(),
|
||||
);
|
||||
weekStart.setUTCHours(0, 0, 0, 0);
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setUTCDate(weekStart.getUTCDate() + 7);
|
||||
|
||||
@@ -156,7 +156,10 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
} catch (calendarError) {
|
||||
console.error("Google Calendar error on proposal approval:", calendarError);
|
||||
console.error(
|
||||
"Google Calendar error on proposal approval:",
|
||||
calendarError,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,10 @@ import { type NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getGoogleCalendarClient } from "@/lib/google-calendar";
|
||||
import { prisma } from "@/lib/prisma.server";
|
||||
import { sendBookingCompletedGuestEmail, sendBookingStatusChangedEmail } from "@/lib/resend";
|
||||
import {
|
||||
sendBookingCompletedGuestEmail,
|
||||
sendBookingStatusChangedEmail,
|
||||
} from "@/lib/resend";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
@@ -120,7 +123,10 @@ export async function PATCH(
|
||||
timezone: user.timezone,
|
||||
};
|
||||
|
||||
await sendBookingStatusChangedEmail({ to: session.user.email, ...emailParams });
|
||||
await sendBookingStatusChangedEmail({
|
||||
to: session.user.email,
|
||||
...emailParams,
|
||||
});
|
||||
|
||||
if (status === "completed") {
|
||||
await sendBookingCompletedGuestEmail({
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
await sendProposalReceivedEmail({
|
||||
to: booking.user.email!,
|
||||
to: booking.user.email,
|
||||
guestName,
|
||||
guestEmail,
|
||||
eventTitle: booking.eventType.title,
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function GET(request: NextRequest) {
|
||||
(b) => b.startTime >= weekStart && b.startTime < weekEnd,
|
||||
).length;
|
||||
|
||||
if (count >= eventType.maxBookingsPerWeek!) {
|
||||
if (count >= eventType.maxBookingsPerWeek) {
|
||||
const weekEndInclusive = new Date(weekEnd);
|
||||
weekEndInclusive.setDate(weekEndInclusive.getDate() - 1);
|
||||
fullWeeks.push({
|
||||
|
||||
@@ -46,7 +46,9 @@ export default async function BookingSuccessPage({
|
||||
<p className="font-medium text-sm">Guest Details</p>
|
||||
<p className="text-sm text-muted-foreground">{guestName}</p>
|
||||
{guestEmail && (
|
||||
<p className="text-sm text-muted-foreground">{guestEmail}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{guestEmail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +151,9 @@ export default function ReschedulePage({ params }: ReschedulePageProps) {
|
||||
</h3>
|
||||
|
||||
{loadingSlots ? (
|
||||
<p className="text-sm text-muted-foreground">Loading slots...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading slots...
|
||||
</p>
|
||||
) : availableSlots.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No available slots for this date
|
||||
|
||||
@@ -64,7 +64,10 @@ export default async function BookingsPage() {
|
||||
<CardTitle>Upcoming</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BookingsTable bookings={confirmed} emptyMessage="No upcoming bookings" />
|
||||
<BookingsTable
|
||||
bookings={confirmed}
|
||||
emptyMessage="No upcoming bookings"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -65,139 +65,135 @@ export default async function NewEventTypePage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/dashboard/event-types"
|
||||
className="text-indigo-600 hover:text-indigo-700 text-sm"
|
||||
>
|
||||
← Back to Event Types
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold mt-4">Create Event Type</h1>
|
||||
<p className="text-muted-foreground dark:text-gray-400 mt-2">
|
||||
Define a new type of meeting you want to offer
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href="/dashboard/event-types"
|
||||
className="text-indigo-600 hover:text-indigo-700 text-sm"
|
||||
>
|
||||
← Back to Event Types
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold mt-4">Create Event Type</h1>
|
||||
<p className="text-muted-foreground dark:text-gray-400 mt-2">
|
||||
Define a new type of meeting you want to offer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Event Details</CardTitle>
|
||||
<CardDescription>
|
||||
Provide information about this event type
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createEventType} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder="30 Minute Meeting"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<CardHeader>
|
||||
<CardTitle>Event Details</CardTitle>
|
||||
<CardDescription>
|
||||
Provide information about this event type
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createEventType} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder="30 Minute Meeting"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">URL Slug *</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
name="slug"
|
||||
placeholder="30min"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
title="Only lowercase letters, numbers, and hyphens"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will be part of your booking URL
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">URL Slug *</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
name="slug"
|
||||
placeholder="30min"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
title="Only lowercase letters, numbers, and hyphens"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will be part of your booking URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="A brief 30-minute meeting to discuss..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="A brief 30-minute meeting to discuss..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duration">Duration (minutes) *</Label>
|
||||
<Input
|
||||
id="duration"
|
||||
name="duration"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
min="15"
|
||||
max="240"
|
||||
step="15"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duration">Duration (minutes) *</Label>
|
||||
<Input
|
||||
id="duration"
|
||||
name="duration"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
min="15"
|
||||
max="240"
|
||||
step="15"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxBookingsPerWeek">
|
||||
Max Bookings Per Week
|
||||
</Label>
|
||||
<Input
|
||||
id="maxBookingsPerWeek"
|
||||
name="maxBookingsPerWeek"
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
min="1"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leave empty for unlimited bookings
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxBookingsPerWeek">Max Bookings Per Week</Label>
|
||||
<Input
|
||||
id="maxBookingsPerWeek"
|
||||
name="maxBookingsPerWeek"
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
min="1"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leave empty for unlimited bookings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minimumNoticeHours">
|
||||
Minimum Notice (hours) *
|
||||
</Label>
|
||||
<Input
|
||||
id="minimumNoticeHours"
|
||||
name="minimumNoticeHours"
|
||||
type="number"
|
||||
placeholder="24"
|
||||
defaultValue="24"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Minimum hours before event can start (e.g., 24 or 48)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minimumNoticeHours">
|
||||
Minimum Notice (hours) *
|
||||
</Label>
|
||||
<Input
|
||||
id="minimumNoticeHours"
|
||||
name="minimumNoticeHours"
|
||||
type="number"
|
||||
placeholder="24"
|
||||
defaultValue="24"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Minimum hours before event can start (e.g., 24 or 48)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maximumNoticeDays">
|
||||
Maximum Notice (days) *
|
||||
</Label>
|
||||
<Input
|
||||
id="maximumNoticeDays"
|
||||
name="maximumNoticeDays"
|
||||
type="number"
|
||||
placeholder="14"
|
||||
defaultValue="14"
|
||||
min="1"
|
||||
max="365"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Maximum days in advance for booking (e.g., 7 or 14)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maximumNoticeDays">Maximum Notice (days) *</Label>
|
||||
<Input
|
||||
id="maximumNoticeDays"
|
||||
name="maximumNoticeDays"
|
||||
type="number"
|
||||
placeholder="14"
|
||||
defaultValue="14"
|
||||
min="1"
|
||||
max="365"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Maximum days in advance for booking (e.g., 7 or 14)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit">Create Event Type</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/dashboard/event-types">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit">Create Event Type</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/dashboard/event-types">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Calendar, LogOut, Menu } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth, signOut } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
async function handleSignOut() {
|
||||
@@ -88,7 +88,11 @@ export default async function DashboardLayout({
|
||||
<nav className="flex flex-col gap-2">
|
||||
{navItems.map((item) => (
|
||||
<SheetClose asChild key={item.href}>
|
||||
<Button variant="ghost" asChild className="justify-start">
|
||||
<Button
|
||||
variant="ghost"
|
||||
asChild
|
||||
className="justify-start"
|
||||
>
|
||||
<a href={item.href}>{item.label}</a>
|
||||
</Button>
|
||||
</SheetClose>
|
||||
|
||||
@@ -24,7 +24,9 @@ export function BookingLinkCard({ username }: { username: string }) {
|
||||
return (
|
||||
<Card className="mb-6 bg-indigo-50 dark:bg-indigo-950 border-indigo-200 dark:border-indigo-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-indigo-900 dark:text-indigo-100">Your Booking Link</CardTitle>
|
||||
<CardTitle className="text-indigo-900 dark:text-indigo-100">
|
||||
Your Booking Link
|
||||
</CardTitle>
|
||||
<CardDescription className="text-indigo-700 dark:text-indigo-300">
|
||||
Share this link with people to allow them to book meetings with you
|
||||
</CardDescription>
|
||||
|
||||
@@ -23,7 +23,13 @@ export default async function SettingsPage() {
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { username: true, name: true, email: true, timezone: true, image: true },
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
timezone: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
+5
-1
@@ -38,7 +38,11 @@ export default function Home() {
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/login" className="w-full sm:w-auto">
|
||||
<Button size="lg" variant="outline" className="text-lg px-8 py-6 w-full sm:w-auto">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="text-lg px-8 py-6 w-full sm:w-auto"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -34,9 +34,7 @@ export default function PrivacyPolicyPage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-3">
|
||||
2. Data We Store
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold mb-3">2. Data We Store</h2>
|
||||
<p className="text-muted-foreground mb-2">
|
||||
When you create an account, we store:
|
||||
</p>
|
||||
@@ -138,8 +136,8 @@ export default function PrivacyPolicyPage() {
|
||||
</code>{" "}
|
||||
scopes to identify your account and display your name and
|
||||
profile picture. If you connect your Google Calendar, we
|
||||
also request OAuth access to read and write calendar
|
||||
events on your behalf (specifically, the{" "}
|
||||
also request OAuth access to read and write calendar events
|
||||
on your behalf (specifically, the{" "}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||
calendar.events
|
||||
</code>{" "}
|
||||
|
||||
@@ -34,7 +34,9 @@ export default function TermsOfUsePage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-3">2. What Event.me Is</h2>
|
||||
<h2 className="text-xl font-semibold mb-3">
|
||||
2. What Event.me Is
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Event.me is a scheduling platform. It lets you define your
|
||||
availability, create bookable event types, and share a link so
|
||||
@@ -55,8 +57,8 @@ export default function TermsOfUsePage() {
|
||||
account.
|
||||
</li>
|
||||
<li>
|
||||
You must provide accurate information when registering and keep
|
||||
it up to date.
|
||||
You must provide accurate information when registering and
|
||||
keep it up to date.
|
||||
</li>
|
||||
<li>You must be at least 13 years old to use Event.me.</li>
|
||||
<li>
|
||||
@@ -217,7 +219,10 @@ export default function TermsOfUsePage() {
|
||||
<h2 className="text-xl font-semibold mb-3">14. Contact</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Questions about these terms can be sent to{" "}
|
||||
<a href="mailto:ricardompcampos@gmail.com" className="underline">
|
||||
<a
|
||||
href="mailto:ricardompcampos@gmail.com"
|
||||
className="underline"
|
||||
>
|
||||
ricardompcampos@gmail.com
|
||||
</a>
|
||||
.
|
||||
|
||||
+102
-95
@@ -153,110 +153,117 @@ export function BookingForm({ eventType }: BookingFormProps) {
|
||||
/>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Select a date</h3>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
disabled={[
|
||||
(date) =>
|
||||
date <
|
||||
startOfDay(
|
||||
addMinutes(new Date(), eventType.minimumNoticeHours * 60),
|
||||
),
|
||||
...fullWeekRanges,
|
||||
]}
|
||||
showOutsideDays={false}
|
||||
className="rounded-md border"
|
||||
/>
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="text-sm text-muted-foreground"
|
||||
onClick={() => setProposalOpen(true)}
|
||||
>
|
||||
Can't find a time that works?
|
||||
</Button>
|
||||
</div>
|
||||
<h3 className="font-semibold mb-4">Select a date</h3>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
disabled={[
|
||||
(date) =>
|
||||
date <
|
||||
startOfDay(
|
||||
addMinutes(new Date(), eventType.minimumNoticeHours * 60),
|
||||
),
|
||||
...fullWeekRanges,
|
||||
]}
|
||||
showOutsideDays={false}
|
||||
className="rounded-md border"
|
||||
/>
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="text-sm text-muted-foreground"
|
||||
onClick={() => setProposalOpen(true)}
|
||||
>
|
||||
Can't find a time that works?
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{selectedDate && (
|
||||
<>
|
||||
<h3 className="font-semibold mb-4">
|
||||
Available times - {format(selectedDate, "MMMM d, yyyy")}
|
||||
</h3>
|
||||
{selectedDate && (
|
||||
<>
|
||||
<h3 className="font-semibold mb-4">
|
||||
Available times - {format(selectedDate, "MMMM d, yyyy")}
|
||||
</h3>
|
||||
|
||||
{loadingSlots ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading slots...
|
||||
</p>
|
||||
) : availableSlots.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No available slots for this date
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-1 gap-2 mb-6">
|
||||
{availableSlots.map((slot) => (
|
||||
<Button
|
||||
key={slot.start.toISOString()}
|
||||
type="button"
|
||||
variant={
|
||||
selectedSlot?.start.getTime() === slot.start.getTime()
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
className="w-full"
|
||||
onClick={() => setSelectedSlot(slot)}
|
||||
>
|
||||
{format(slot.start, "HH:mm")} -{" "}
|
||||
{format(slot.end, "HH:mm")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSlot && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="guestName">Name *</Label>
|
||||
<Input
|
||||
id="guestName"
|
||||
name="guestName"
|
||||
type="text"
|
||||
required
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="guestEmail">Email *</Label>
|
||||
<Input
|
||||
id="guestEmail"
|
||||
name="guestEmail"
|
||||
type="email"
|
||||
required
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="guestNotes">Notes (optional)</Label>
|
||||
<Textarea
|
||||
id="guestNotes"
|
||||
name="guestNotes"
|
||||
placeholder="Any additional information..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loadingSlots ? (
|
||||
<p className="text-sm text-muted-foreground">Loading slots...</p>
|
||||
) : availableSlots.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No available slots for this date
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-1 gap-2 mb-6">
|
||||
{availableSlots.map((slot) => (
|
||||
<Button
|
||||
key={slot.start.toISOString()}
|
||||
type="button"
|
||||
variant={
|
||||
selectedSlot?.start.getTime() === slot.start.getTime()
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
type="submit"
|
||||
className="w-full"
|
||||
onClick={() => setSelectedSlot(slot)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{format(slot.start, "HH:mm")} - {format(slot.end, "HH:mm")}
|
||||
{submitting ? "Booking..." : "Confirm booking"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSlot && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="guestName">Name *</Label>
|
||||
<Input
|
||||
id="guestName"
|
||||
name="guestName"
|
||||
type="text"
|
||||
required
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="guestEmail">Email *</Label>
|
||||
<Input
|
||||
id="guestEmail"
|
||||
name="guestEmail"
|
||||
type="email"
|
||||
required
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="guestNotes">Notes (optional)</Label>
|
||||
<Textarea
|
||||
id="guestNotes"
|
||||
name="guestNotes"
|
||||
placeholder="Any additional information..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={submitting}>
|
||||
{submitting ? "Booking..." : "Confirm booking"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,8 +123,8 @@ export function BookingsTable({
|
||||
|
||||
<div className="text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Guest:</span> {booking.guestName}{" "}
|
||||
({booking.guestEmail})
|
||||
<span className="font-medium text-foreground">Guest:</span>
|
||||
{booking.guestName} ({booking.guestEmail})
|
||||
</p>
|
||||
{booking.guestNotes && (
|
||||
<p className="text-muted-foreground mt-1">
|
||||
@@ -145,7 +145,9 @@ export function BookingsTable({
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span className="ml-1">{completing === booking.id ? "Completing..." : "Complete"}</span>
|
||||
<span className="ml-1">
|
||||
{completing === booking.id ? "Completing..." : "Complete"}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -164,7 +166,9 @@ export function BookingsTable({
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<X className="w-4 h-4 shrink-0" />
|
||||
<span className="ml-1">{cancelling === booking.id ? "Cancelling..." : "Cancel"}</span>
|
||||
<span className="ml-1">
|
||||
{cancelling === booking.id ? "Cancelling..." : "Cancel"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,9 @@ interface ProposalsSectionProps {
|
||||
proposals: (Booking & { eventType: EventType })[];
|
||||
}
|
||||
|
||||
export function ProposalsSection({ proposals: initial }: ProposalsSectionProps) {
|
||||
export function ProposalsSection({
|
||||
proposals: initial,
|
||||
}: ProposalsSectionProps) {
|
||||
const [proposals, setProposals] = useState(initial);
|
||||
|
||||
const handleResponded = (id: string) => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes";
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
type ThemeProviderProps,
|
||||
} from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
|
||||
@@ -54,7 +54,10 @@ function DialogContent({
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -66,7 +69,10 @@ function DialogTitle({
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -60,10 +60,7 @@ function SheetContent({
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div className={cn("flex flex-col space-y-1.5", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -1,5 +1,12 @@
|
||||
import type { Availability, Booking, EventType } from "@prisma/client";
|
||||
import { addDays, addMinutes, format, isAfter, isBefore, startOfDay } from "date-fns";
|
||||
import {
|
||||
addDays,
|
||||
addMinutes,
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
startOfDay,
|
||||
} from "date-fns";
|
||||
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
||||
|
||||
export interface TimeSlot {
|
||||
|
||||
Generated
+17
-14
@@ -44,8 +44,8 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -8754,11 +8754,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
|
||||
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
|
||||
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcryptjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
@@ -8786,12 +8790,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
@@ -11120,7 +11124,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -15916,9 +15919,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
|
||||
Reference in New Issue
Block a user