diff --git a/.gitignore b/.gitignore index f472787..fe1825f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,11 @@ npm-debug.log* # Test coverage coverage/ +# Playwright test artifacts +test-results/ +tests/screenshots/ +tests/final-screenshots/ + # TypeScript *.tsbuildinfo diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json index f288fc5..68434f7 100644 --- a/.kilo/package-lock.json +++ b/.kilo/package-lock.json @@ -5,22 +5,22 @@ "packages": { "": { "dependencies": { - "@kilocode/plugin": "7.2.40" + "@kilocode/plugin": "7.2.52" } }, "node_modules/@kilocode/plugin": { - "version": "7.2.40", - "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.40.tgz", - "integrity": "sha512-m0/5LnQdKW+FJnv9sVbri4Cqw7vq2jy5xS7hdkWO0mbyPkjROcrXklnwuHk3bi32F5InnO4DUyKrkqfMFoMDgQ==", + "version": "7.2.52", + "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.52.tgz", + "integrity": "sha512-ivFOFH2oY4QU3NVyA2Z5mD5jPBoqvwCjXK5f6lUGBJtrI+diDcA9BLHeXlXgwNvuMbO49xw3mOKSyLlyqGpeUg==", "license": "MIT", "dependencies": { - "@kilocode/sdk": "7.2.40", + "@kilocode/sdk": "7.2.52", "effect": "4.0.0-beta.57", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.1.105", - "@opentui/solid": ">=0.1.105" + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2" }, "peerDependenciesMeta": { "@opentui/core": { @@ -32,9 +32,9 @@ } }, "node_modules/@kilocode/sdk": { - "version": "7.2.40", - "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.40.tgz", - "integrity": "sha512-I/AWGE2EnM26/lWD/gf9T8QxuCB/YPZBQJ14M8D/SN4+rZKonyqz2Q6iIgQWqcX3B4LQLMWNFNh9rdvPaHN66Q==", + "version": "7.2.52", + "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.52.tgz", + "integrity": "sha512-j8w6ewvo7dyu/qxjJAg0bcjHGUGGvIZ4F2f5tJnpMwLzPTAu26DJoO/08aoxf1BhfuZLzNS9tA2q+ZPdzPT8Jg==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" diff --git a/.kilo/skills/blog/SKILL.md b/.kilo/skills/blog/SKILL.md new file mode 100644 index 0000000..f9ac104 --- /dev/null +++ b/.kilo/skills/blog/SKILL.md @@ -0,0 +1,400 @@ +--- +name: blog +description: Blog/CMS domain knowledge - posts, categories, tags, comments, authors, SEO, RSS, scheduling +--- + +# Blog Skill + +## Purpose + +Universal domain knowledge for building blog and content management systems: posts, categories, tags, comments, authors, SEO optimization, RSS/sitemaps. + +## Capabilities + +### Content Management +- Post CRUD operations +- Draft / Published / Archived states +- Content scheduling +- Rich text / Markdown editing +- Media embedding +- Featured images + +### Organization +- Hierarchical categories +- Flat tags +- Author assignment +- Content series / collections + +### Comments +- Comment moderation +- Threaded (nested) comments +- Spam filtering +- Guest and authenticated comments + +### SEO +- Meta tags (title, description) +- Open Graph / Twitter Cards +- Structured data (Schema.org Article) +- XML sitemap +- RSS / Atom feeds +- Canonical URLs + +### Analytics +- View counts +- Reading time estimation +- Popular posts +- Related posts algorithm + +## Database Schema (Universal) + +Adapt table/column names to your framework conventions. Use `id` types appropriate to your DB (INTEGER / UUID / BIGSERIAL). + +### Posts Table + +```sql +CREATE TABLE posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- or UUID / BIGSERIAL + title TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + excerpt TEXT, + content TEXT NOT NULL, + featured_image TEXT, + author_id INTEGER NOT NULL, + category_id INTEGER, + status TEXT DEFAULT 'draft', -- 'draft' | 'published' | 'archived' + published_at DATETIME, + meta_title TEXT, + meta_description TEXT, + canonical_url TEXT, + reading_time INTEGER, -- minutes + view_count INTEGER DEFAULT 0, + allow_comments BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (author_id) REFERENCES users(id), + FOREIGN KEY (category_id) REFERENCES categories(id) +); + +CREATE INDEX idx_posts_slug ON posts(slug); +CREATE INDEX idx_posts_status ON posts(status); +CREATE INDEX idx_posts_published ON posts(published_at); +CREATE INDEX idx_posts_author ON posts(author_id); +CREATE INDEX idx_posts_category ON posts(category_id); +``` + +### Categories Table + +```sql +CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT, + parent_id INTEGER, + image_url TEXT, + meta_title TEXT, + meta_description TEXT, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (parent_id) REFERENCES categories(id) +); + +CREATE INDEX idx_categories_parent ON categories(parent_id); +``` + +### Tags Tables + +```sql +CREATE TABLE tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT +); + +CREATE TABLE post_tags ( + post_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (post_id, tag_id), + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); + +CREATE INDEX idx_post_tags_post ON post_tags(post_id); +CREATE INDEX idx_post_tags_tag ON post_tags(tag_id); +``` + +### Comments Table + +```sql +CREATE TABLE comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + parent_id INTEGER, -- for threaded comments; NULL = top-level + author_name TEXT NOT NULL, + author_email TEXT NOT NULL, + author_url TEXT, + content TEXT NOT NULL, + status TEXT DEFAULT 'pending', -- 'pending' | 'approved' | 'spam' | 'trash' + ip_address TEXT, + user_agent TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES comments(id) +); + +CREATE INDEX idx_comments_post ON comments(post_id); +CREATE INDEX idx_comments_status ON comments(status); +``` + +### Authors Table + +```sql +CREATE TABLE authors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE, + name TEXT NOT NULL, + bio TEXT, + avatar TEXT, + social_links TEXT, -- JSON {"twitter":"...","linkedin":"..."} + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); +``` + +## API Endpoints + +### Public API + +```yaml +GET /api/posts # List published posts (paginated) +GET /api/posts/:slug # Get single post by slug +GET /api/posts/author/:author # Posts by author slug +GET /api/posts/category/:slug # Posts by category slug +GET /api/posts/tag/:slug # Posts by tag slug +GET /api/posts/search # Search posts (?q=query) + +GET /api/categories # List categories (tree or flat) +GET /api/categories/:slug # Category detail + posts + +GET /api/tags # List tags +GET /api/tags/:slug # Tag detail + posts + +GET /api/posts/:slug/comments # Approved comments (paginated) +POST /api/posts/:slug/comments # Submit new comment + +GET /api/feed/rss # RSS feed +GET /api/feed/atom # Atom feed +GET /api/sitemap.xml # XML sitemap +``` + +### Admin / CMS API + +```yaml +GET /api/admin/posts # List all posts (all statuses) +POST /api/admin/posts # Create post +PUT /api/admin/posts/:id # Update post +DELETE /api/admin/posts/:id # Delete post +POST /api/admin/posts/:id/publish +POST /api/admin/posts/:id/archive + +GET /api/admin/categories # List categories +POST /api/admin/categories # Create category +PUT /api/admin/categories/:id # Update category +DELETE /api/admin/categories/:id # Delete category + +GET /api/admin/tags # List tags +POST /api/admin/tags # Create tag +PUT /api/admin/tags/:id # Update tag +DELETE /api/admin/tags/:id # Delete tag + +GET /api/admin/comments # List comments (all statuses) +PUT /api/admin/comments/:id/approve +PUT /api/admin/comments/:id/spam +DELETE /api/admin/comments/:id + +POST /api/admin/media/upload # Upload media +GET /api/admin/media # List media +DELETE /api/admin/media/:id # Delete media +``` + +## Markdown & Rich Content + +Use any framework-appropriate parser (e.g. `marked`, `commonmark`, `python-markdown`). + +```javascript +// Example with marked + highlight.js +const marked = require('marked'); +const hljs = require('highlight.js'); + +marked.use({ + renderer: { + code(code, language) { + const highlighted = language + ? hljs.highlight(code, { language }).value + : code; + return `
${highlighted}
`; + }, + image(href, title, alt) { + return `
${alt}
${alt}
`; + } + } +}); + +function calculateReadingTime(content) { + const words = content.split(/\s+/).length; + return Math.ceil(words / 200); // 200 wpm average +} +``` + +## SEO Implementation + +### Meta Tags Generator + +```javascript +function generateMeta(post, siteConfig) { + return { + title: post.meta_title || `${post.title} | ${siteConfig.name}`, + description: post.meta_description || post.excerpt, + canonical: post.canonical_url || `${siteConfig.url}/posts/${post.slug}`, + ogType: 'article', + ogImage: post.featured_image, + articlePublishedTime: post.published_at, + articleAuthor: post.author?.name + }; +} +``` + +### Schema.org Article JSON-LD + +```javascript +function generateArticleSchema(post) { + return { + "@context": "https://schema.org", + "@type": "Article", + headline: post.title, + image: post.featured_image, + author: { "@type": "Person", name: post.author.name }, + datePublished: post.published_at, + dateModified: post.updated_at, + description: post.excerpt + }; +} +``` + +### Sitemap Template + +```xml + + + {{siteUrl}}/daily1.0 + {{#posts}} + + {{siteUrl}}/posts/{{slug}} + {{updated_at}} + weekly + 0.8 + + {{/posts}} + +``` + +## Comment Moderation + +### Spam Detection Signals + +```javascript +function detectSpam(comment) { + const signals = []; + + if (countLinks(comment.content) > 3) signals.push('excessive_links'); + if (isDuplicate(comment)) signals.push('duplicate'); + if (isBlacklisted(comment.ip_address)) signals.push('blacklisted_ip'); + if (containsSpamWords(comment.content)) signals.push('spam_words'); + + return { isSpam: signals.length >= 2, score: signals.length, signals }; +} +``` + +## Content Scheduling + +```javascript +async function publishScheduledPosts(db) { + const now = new Date(); + const posts = await db.posts.findAll({ + where: { status: 'draft', published_at: { [Op.lte]: now } } + }); + for (const post of posts) { + await post.update({ status: 'published' }); + // notify subscribers, regenerate sitemap, etc. + } +} +// Run via cron / scheduler every minute or use framework queue system. +``` + +## Related Posts Algorithm + +```javascript +async function getRelatedPosts(db, post, limit = 5) { + return db.posts.findAll({ + where: { + id: { [Op.ne]: post.id }, + status: 'published', + [Op.or]: [ + { category_id: post.category_id }, + { '$tags.id$': post.tags.map(t => t.id) } + ] + }, + include: [db.tags], + limit, + order: [['published_at', 'DESC']] + }); +} +``` + +## Caching Strategy + +```javascript +async function getPopularPosts(cache, db, ttlSeconds = 3600) { + const key = 'popular-posts'; + const cached = await cache.get(key); + if (cached) return cached; + + const posts = await db.posts.findAll({ + where: { status: 'published' }, + order: [['view_count', 'DESC']], + limit: 10 + }); + + await cache.set(key, posts, ttlSeconds); + return posts; +} +``` + +## Security Checklist + +- [ ] Sanitize all user HTML input (use allow-list approach, e.g. DOMPurify) +- [ ] Validate author permissions before edit / delete +- [ ] Rate-limit comment submissions (e.g. 5 per minute per IP) +- [ ] CSRF protection on all state-changing forms +- [ ] CAPTCHA for guest comments +- [ ] No raw SQL concatenation — use parameterized queries + +## Integration Points + +| Concern | Options | +|---------|---------| +| Email subscribers | SendGrid, Mailchimp, Resend, framework mailer | +| CDN / image storage | Cloudflare R2, AWS S3, MinIO | +| Analytics | Plausible, Google Analytics, Umami | +| Search | Algolia, Meilisearch, Elasticsearch, Postgres full-text | +| Comments external | Disqus, Facebook Comments, Giscus | + +## Handoff Protocol + +After implementation: +1. End-to-end test: create → publish → view → comment +2. Verify SEO meta tags and Open Graph +3. Validate RSS feed and sitemap XML +4. Test comment spam filtering +5. Confirm scheduled publishing works +6. Security audit via `@security-auditor` diff --git a/.kilo/skills/booking/SKILL.md b/.kilo/skills/booking/SKILL.md new file mode 100644 index 0000000..9a031f3 --- /dev/null +++ b/.kilo/skills/booking/SKILL.md @@ -0,0 +1,547 @@ +--- +name: booking +description: Booking domain knowledge - appointments, services, staff, schedules, availability, reservations, notifications, calendar sync +--- + +# Booking Skill + +## Purpose + +Universal domain knowledge for building booking and appointment systems: services, staff, schedules, availability, reservations, notifications, calendar sync. + +## Capabilities + +### Service Management +- Service categories +- Service duration and pricing +- Service variants (different durations / prices) +- Required resources / rooms +- Active / inactive toggle + +### Staff Management +- Staff profiles and roles (admin / manager / staff) +- Specializations (linked to services) +- Availability schedules +- Time-off / vacation tracking + +### Booking Flow +- Service selection +- Staff selection (optional or auto-assign) +- Date / time slot selection +- Customer details (guest or registered) +- Confirmation +- Payment (optional) + +### Scheduling +- Working hours per day of week +- Break times +- Days off +- Multi-location support +- Timezone handling +- Advance booking limits (min / max) + +### Notifications +- Email confirmations +- SMS reminders +- Calendar attachments (iCal / Google Calendar) +- Push notifications (if applicable) + +### Admin Dashboard +- Booking list with filters (status, date, staff) +- Availability editor +- Customer CRM view +- Revenue reports +- Staff utilization + +## Database Schema (Universal) + +Use `id` types appropriate to your DB (INTEGER / UUID / BIGSERIAL). Adapt table/column names to your framework conventions. + +### Services + +```sql +CREATE TABLE service_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT, + image_url TEXT, + sort_order INTEGER DEFAULT 0 +); + +CREATE TABLE services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER, + name TEXT NOT NULL, + description TEXT, + duration INTEGER NOT NULL, -- minutes + price DECIMAL(10,2) NOT NULL, + buffer_time INTEGER DEFAULT 0, -- minutes between appointments + max_per_slot INTEGER DEFAULT 1, + is_active BOOLEAN DEFAULT 1, + image_url TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES service_categories(id) +); + +-- Optional: per-staff overrides +CREATE TABLE service_staff ( + service_id INTEGER NOT NULL, + staff_id INTEGER NOT NULL, + custom_price DECIMAL(10,2), + custom_duration INTEGER, + PRIMARY KEY (service_id, staff_id), + FOREIGN KEY (service_id) REFERENCES services(id), + FOREIGN KEY (staff_id) REFERENCES staff(id) +); +``` + +### Staff + +```sql +CREATE TABLE staff ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + phone TEXT, + avatar_url TEXT, + bio TEXT, + role TEXT DEFAULT 'staff', -- 'admin' | 'manager' | 'staff' + is_active BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE staff_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + staff_id INTEGER NOT NULL, + day_of_week INTEGER NOT NULL, -- 0=Sun ... 6=Sat + start_time TEXT NOT NULL, -- '09:00' + end_time TEXT NOT NULL, -- '17:00' + break_start TEXT, -- nullable + break_end TEXT, -- nullable + is_working BOOLEAN DEFAULT 1, + FOREIGN KEY (staff_id) REFERENCES staff(id) +); + +CREATE TABLE staff_time_off ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + staff_id INTEGER NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + reason TEXT, + FOREIGN KEY (staff_id) REFERENCES staff(id) +); +``` + +### Bookings + +```sql +CREATE TABLE bookings ( + id TEXT PRIMARY KEY, -- UUID recommended + booking_number TEXT UNIQUE NOT NULL, + service_id INTEGER NOT NULL, + staff_id INTEGER, + customer_id INTEGER, + + -- Guest booking support + customer_name TEXT NOT NULL, + customer_email TEXT NOT NULL, + customer_phone TEXT, + customer_notes TEXT, + + booking_date DATE NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + + status TEXT DEFAULT 'pending', -- 'pending' | 'confirmed' | 'completed' | 'cancelled' | 'no_show' + + service_price DECIMAL(10,2) NOT NULL, + addons_total DECIMAL(10,2) DEFAULT 0, + discount DECIMAL(10,2) DEFAULT 0, + total DECIMAL(10,2) NOT NULL, + + payment_method TEXT, -- 'cash' | 'card' | 'online' + payment_status TEXT DEFAULT 'pending', -- 'pending' | 'paid' | 'refunded' + + source TEXT DEFAULT 'website', -- 'website' | 'phone' | 'walk_in' + notes TEXT, + internal_notes TEXT, + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (service_id) REFERENCES services(id), + FOREIGN KEY (staff_id) REFERENCES staff(id), + FOREIGN KEY (customer_id) REFERENCES customers(id) +); + +CREATE INDEX idx_bookings_date ON bookings(booking_date); +CREATE INDEX idx_bookings_staff ON bookings(staff_id); +CREATE INDEX idx_bookings_status ON bookings(status); +``` + +### Customers + +```sql +CREATE TABLE customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + phone TEXT, + date_of_birth DATE, + notes TEXT, + total_visits INTEGER DEFAULT 0, + total_spent DECIMAL(10,2) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_customers_email ON customers(email); +CREATE INDEX idx_customers_phone ON customers(phone); +``` + +### Settings (Key-Value) + +```sql +CREATE TABLE booking_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + type TEXT DEFAULT 'string' -- 'string' | 'number' | 'boolean' | 'json' +); +``` + +Default settings to insert: + +| key | value | type | +|---|---|---| +| timezone | Europe/Moscow | string | +| currency | RUB | string | +| booking_interval | 30 | number | +| min_booking_notice | 60 | number | +| max_booking_advance | 30 | number | +| require_phone | 1 | boolean | +| require_payment | 0 | boolean | +| deposit_percentage | 20 | number | +| cancellation_hours | 24 | number | +| reminder_hours | 2 | number | +| confirmation_email | 1 | boolean | +| confirmation_sms | 0 | boolean | + +## API Endpoints + +### Public API + +```yaml +GET /api/services # Active services +GET /api/services/:id # Service detail +GET /api/categories # Service categories + +GET /api/staff # Active staff +GET /api/staff/:id # Staff detail +GET /api/staff/:id/availability # Availability slots + +GET /api/availability # Available slots (?service_id=&date=) +POST /api/availability/check # Check specific slot + +POST /api/bookings # Create booking (guest) +GET /api/bookings/:id # Booking detail +POST /api/bookings/:id/cancel # Cancel booking +POST /api/bookings/:id/reschedule # Reschedule + +GET /api/customer/bookings # My bookings (auth) +POST /api/customer/register # Register customer +``` + +### Admin API + +```yaml +GET /api/admin/services # All services +POST /api/admin/services # Create +PUT /api/admin/services/:id # Update +DELETE /api/admin/services/:id # Delete + +GET /api/admin/categories # Categories +POST /api/admin/categories # Create +PUT /api/admin/categories/:id # Update +DELETE /api/admin/categories/:id # Delete + +GET /api/admin/staff # All staff +POST /api/admin/staff # Add +PUT /api/admin/staff/:id # Update +DELETE /api/admin/staff/:id # Remove +PUT /api/admin/staff/:id/schedule # Update schedule +POST /api/admin/staff/:id/time-off # Add time off + +GET /api/admin/bookings # List (filtered) +GET /api/admin/bookings/:id # Detail +PUT /api/admin/bookings/:id/confirm +PUT /api/admin/bookings/:id/complete +PUT /api/admin/bookings/:id/cancel +PUT /api/admin/bookings/:id/no-show + +GET /api/admin/calendar # Calendar view +GET /api/admin/calendar/staff/:id # Staff calendar + +GET /api/admin/customers # Customers +GET /api/admin/customers/:id # Detail + history + +GET /api/admin/reports/revenue # Revenue report +GET /api/admin/reports/services # Service popularity +GET /api/admin/reports/staff # Utilization +GET /api/admin/reports/trends # Booking trends + +GET /api/admin/settings # All settings +PUT /api/admin/settings # Update +``` + +## Availability Algorithm + +### Generate Available Slots + +```javascript +function getAvailableSlots(service, staffSchedule, existingBookings, date) { + const dayOfWeek = new Date(date).getDay(); + const schedule = staffSchedule.find(s => s.day_of_week === dayOfWeek && s.is_working); + if (!schedule) return []; + + const slots = []; + let current = parseTime(schedule.start_time); + const end = parseTime(schedule.end_time); + const duration = service.duration + (service.buffer_time || 0); + + while (addMinutes(current, duration) <= end) { + const slotStart = formatTime(current); + const slotEnd = formatTime(addMinutes(current, service.duration)); + + const isBooked = existingBookings.some(b => + b.start_time < slotEnd && b.end_time > slotStart + ); + + const isBreak = schedule.break_start && schedule.break_end && + slotStart < schedule.break_end && slotEnd > schedule.break_start; + + const slotDateTime = new Date(`${date}T${slotStart}`); + const isTooSoon = slotDateTime < addMinutes(new Date(), settings.min_booking_notice); + const isPast = slotDateTime < new Date(); + + if (!isBooked && !isBreak && !isTooSoon && !isPast) { + slots.push({ start_time: slotStart, end_time: slotEnd, available: true }); + } + + current = addMinutes(current, duration); + } + return slots; +} +``` + +### Create Booking + +```javascript +async function createBooking(db, data) { + const service = await db.services.findById(data.service_id); + if (!service || !service.is_active) throw new Error('Service unavailable'); + + const staff = data.staff_id + ? await db.staff.findById(data.staff_id) + : await autoAssignStaff(db, service.id, data.date, data.time); + if (!staff) throw new Error('No staff available'); + + const available = await checkAvailability(db, service.id, staff.id, data.date, data.time); + if (!available) throw new Error('Slot already booked'); + + const endTime = addMinutes(parseTime(data.time), service.duration); + + const booking = await db.bookings.create({ + id: generateUUID(), + booking_number: generateBookingNumber(), + service_id: service.id, + staff_id: staff.id, + customer_name: data.customer_name, + customer_email: data.customer_email, + customer_phone: data.customer_phone, + booking_date: data.date, + start_time: data.time, + end_time: formatTime(endTime), + status: 'pending', + service_price: service.price, + total: service.price + }); + + // Optional: lock slot in availability cache / table + await sendConfirmation(booking, service, staff); + return booking; +} +``` + +## Booking Status Flow + +``` +pending → confirmed → completed + ↓ ↓ ↓ +cancelled cancelled cancelled → refunded + ↓ +no_show +``` + +Valid transitions: + +| From | To | +|---|---| +| pending | confirmed, cancelled | +| confirmed | completed, cancelled, no_show | +| completed | refunded | +| cancelled | refunded | +| no_show | — | +| refunded | — | + +```javascript +const STATUS_FLOW = { + pending: ['confirmed', 'cancelled'], + confirmed: ['completed', 'cancelled', 'no_show'], + completed: ['refunded'], + cancelled: ['refunded'], + no_show: [], + refunded: [] +}; +``` + +## Notifications + +### Email Templates + +```javascript +function bookingConfirmation(booking, service, staff) { + return { + subject: `Booking Confirmed - ${service.name}`, + html: ` +

Your booking is confirmed!

+

Booking #${booking.booking_number}

+ +

Manage booking

+ ` + }; +} +``` + +### SMS Templates + +```javascript +const smsTemplates = { + confirmation: (b, s) => + `Your ${s.name} booking confirmed for ${formatDate(b.booking_date)} at ${b.start_time}. #${b.booking_number}`, + reminder: (b, s) => + `Reminder: ${s.name} appointment in ${settings.reminder_hours}h at ${b.start_time}.`, + cancellation: b => + `Your booking #${b.booking_number} has been cancelled.` +}; +``` + +## Calendar Integration + +### iCal Export + +```javascript +function generateICal(booking, service, staff) { + return `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:${toICalDate(booking.booking_date, booking.start_time)} +DTEND:${toICalDate(booking.booking_date, booking.end_time)} +SUMMARY:${service.name} with ${staff.name} +DESCRIPTION:Booking #${booking.booking_number} +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR`; +} +``` + +## Reports + +### Revenue Report + +```sql +SELECT + DATE(booking_date) AS date, + COUNT(*) AS bookings, + SUM(total) AS revenue, + AVG(total) AS avg_booking +FROM bookings +WHERE status IN ('completed', 'confirmed') + AND booking_date BETWEEN ? AND ? +GROUP BY DATE(booking_date) +ORDER BY date; +``` + +### Staff Utilization + +```sql +SELECT + s.id, + s.name, + COUNT(b.id) AS bookings, + SUM( + CAST(strftime('%s', b.end_time) AS INTEGER) - + CAST(strftime('%s', b.start_time) AS INTEGER) + ) / 3600.0 AS hours_booked +FROM staff s +LEFT JOIN bookings b ON b.staff_id = s.id + AND b.status IN ('completed', 'confirmed') + AND b.booking_date BETWEEN ? AND ? +GROUP BY s.id +ORDER BY bookings DESC; +``` + +### Service Popularity + +```sql +SELECT + s.id, + s.name, + COUNT(b.id) AS bookings, + SUM(b.total) AS revenue +FROM services s +LEFT JOIN bookings b ON b.service_id = s.id + AND b.status IN ('completed', 'confirmed') + AND b.booking_date BETWEEN ? AND ? +GROUP BY s.id +ORDER BY bookings DESC; +``` + +## Security Checklist + +- [ ] Validate service exists and is active before booking +- [ ] Prevent double-booking via DB unique constraint or atomic check +- [ ] Rate-limit booking creation (e.g. 10/hour per IP) +- [ ] Sanitize customer notes / internal notes +- [ ] No raw SQL — parameterized queries only +- [ ] Admin endpoints protected by role middleware + +## Integration Points + +| Concern | Options | +|---------|---------| +| Payment | Stripe, YooKassa, PayPal | +| SMS | Twilio, SMS.ru, Vonage | +| Email | SendGrid, Mailgun, Resend, framework mailer | +| Calendar | Google Calendar API, iCal attachments | +| Analytics | Google Analytics, Yandex Metrika | + +## Handoff Protocol + +After implementation: +1. End-to-end test: select service → staff → slot → book → confirm +2. Verify no double-booking with concurrent requests +3. Test status transitions and notifications +4. Validate calendar attachment downloads correctly +5. Confirm reports return accurate numbers +6. Security audit via `@security-auditor` diff --git a/.kilo/skills/ecommerce/SKILL.md b/.kilo/skills/ecommerce/SKILL.md new file mode 100644 index 0000000..e743948 --- /dev/null +++ b/.kilo/skills/ecommerce/SKILL.md @@ -0,0 +1,489 @@ +--- +name: ecommerce +description: E-commerce domain knowledge - products, variants, categories, cart, orders, payments, inventory, discounts +--- + +# E-commerce Skill + +## Purpose + +Universal domain knowledge for building e-commerce systems: product catalogs, shopping carts, order processing, payment integration, inventory management, discounts. + +## Capabilities + +### Product Catalog +- Product CRUD with variants (size, color, material) +- Categories (hierarchical) +- Tags / attributes +- Pricing: base, compare-at, cost, per-variant adjustments +- Image galleries +- Search and faceted filtering + +### Shopping Cart +- Add / remove / update quantities +- Guest (session) and persistent (registered) carts +- Price recalculation +- Discount codes +- Shipping estimation + +### Order Processing +- Order creation from cart +- Status workflow with history +- Invoice generation +- Email notifications +- Guest checkout support + +### Payments +- Stripe, PayPal, YooKassa integration patterns +- Payment status tracking +- Refund processing +- Webhook handling (idempotent) + +### Inventory +- Stock tracking per SKU / variant +- Low-stock alerts +- Inventory adjustments with reason +- Concurrent purchase safety (locks or optimistic) + +### Discounts +- Coupon codes (percentage / fixed / free shipping) +- Usage limits (global, per-user, per-code) +- Date validity +- Minimum order requirements + +## Database Schema (Universal) + +Use `id` types appropriate to your DB (INTEGER / UUID / BIGSERIAL). Adapt naming to your framework conventions. + +### Products + +```sql +CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + parent_id INTEGER, + description TEXT, + image_url TEXT, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (parent_id) REFERENCES categories(id) +); + +CREATE TABLE products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + price DECIMAL(10,2) NOT NULL, + compare_at_price DECIMAL(10,2), + cost_price DECIMAL(10,2), + quantity INTEGER DEFAULT 0, -- base stock if no variants + category_id INTEGER, + is_active BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) +); + +CREATE TABLE product_variants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL, + sku TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, -- e.g. "Red / Large" + price_adjustment DECIMAL(10,2) DEFAULT 0, + quantity INTEGER DEFAULT 0, + attributes TEXT, -- JSON {"color":"red","size":"L"} + is_active BOOLEAN DEFAULT 1, + FOREIGN KEY (product_id) REFERENCES products(id) +); + +CREATE TABLE product_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER, + variant_id INTEGER, + url TEXT NOT NULL, + alt_text TEXT, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (product_id) REFERENCES products(id), + FOREIGN KEY (variant_id) REFERENCES product_variants(id) +); +``` + +### Cart + +```sql +CREATE TABLE carts ( + id TEXT PRIMARY KEY, -- UUID + user_id INTEGER, + session_id TEXT, + currency TEXT DEFAULT 'USD', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE cart_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cart_id TEXT NOT NULL, + product_id INTEGER NOT NULL, + variant_id INTEGER, + quantity INTEGER NOT NULL DEFAULT 1, + price DECIMAL(10,2) NOT NULL, -- snapshot at add time + FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products(id), + FOREIGN KEY (variant_id) REFERENCES product_variants(id) +); +``` + +### Orders + +```sql +CREATE TABLE orders ( + id TEXT PRIMARY KEY, -- UUID + order_number TEXT UNIQUE NOT NULL, + user_id INTEGER, + email TEXT NOT NULL, + status TEXT DEFAULT 'pending', -- see status table below + subtotal DECIMAL(10,2) NOT NULL, + tax DECIMAL(10,2) DEFAULT 0, + shipping DECIMAL(10,2) DEFAULT 0, + discount DECIMAL(10,2) DEFAULT 0, + total DECIMAL(10,2) NOT NULL, + currency TEXT DEFAULT 'USD', + shipping_address TEXT, -- JSON or normalized columns + billing_address TEXT, -- JSON or normalized columns + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE order_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id TEXT NOT NULL, + product_id INTEGER NOT NULL, + variant_id INTEGER, + name TEXT NOT NULL, -- snapshot + sku TEXT NOT NULL, -- snapshot + quantity INTEGER NOT NULL, + price DECIMAL(10,2) NOT NULL, -- unit price snapshot + total DECIMAL(10,2) NOT NULL, -- price * quantity + FOREIGN KEY (order_id) REFERENCES orders(id), + FOREIGN KEY (product_id) REFERENCES products(id) +); + +CREATE TABLE order_status_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id TEXT NOT NULL, + status TEXT NOT NULL, + notes TEXT, + created_by INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (order_id) REFERENCES orders(id) +); +``` + +### Payments + +```sql +CREATE TABLE payments ( + id TEXT PRIMARY KEY, -- UUID + order_id TEXT NOT NULL, + provider TEXT NOT NULL, -- 'stripe' | 'paypal' | 'yookassa' + transaction_id TEXT, + amount DECIMAL(10,2) NOT NULL, + currency TEXT DEFAULT 'USD', + status TEXT DEFAULT 'pending', -- 'pending' | 'completed' | 'failed' | 'refunded' + metadata TEXT, -- JSON + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (order_id) REFERENCES orders(id) +); +``` + +### Inventory + +```sql +CREATE TABLE inventory_transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER, + variant_id INTEGER, + quantity INTEGER NOT NULL, + type TEXT NOT NULL, -- 'purchase' | 'sale' | 'adjustment' | 'return' + reference TEXT, -- order_id or PO_id + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (product_id) REFERENCES products(id), + FOREIGN KEY (variant_id) REFERENCES product_variants(id) +); +``` + +### Discounts + +```sql +CREATE TABLE discount_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + type TEXT NOT NULL, -- 'percentage' | 'fixed' | 'free_shipping' + value DECIMAL(10,2) NOT NULL, -- percent or amount + min_order_amount DECIMAL(10,2) DEFAULT 0, + max_discount DECIMAL(10,2), + usage_limit INTEGER, -- total global uses + usage_limit_per_customer INTEGER, + used_count INTEGER DEFAULT 0, + starts_at DATETIME, + expires_at DATETIME, + is_active BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +## Order Statuses + +| Status | Description | Next | +|---|---|---| +| `pending` | Created, awaiting payment | `processing`, `cancelled` | +| `processing` | Payment confirmed, preparing | `shipped`, `on_hold` | +| `shipped` | Dispatched to customer | `delivered` | +| `delivered` | Customer received | `completed` | +| `completed` | Order fulfilled | — | +| `on_hold` | Manual review / fraud check | `processing`, `cancelled` | +| `cancelled` | Order cancelled | `refunded` | +| `refunded` | Money returned | — | + +## API Endpoints + +### Public / Storefront + +```yaml +GET /api/products # List products (paginated, filtered) +GET /api/products/:slug # Product detail with variants +GET /api/categories # Categories tree +GET /api/categories/:slug # Category + products +GET /api/tags # Tags + +GET /api/cart # Current cart +POST /api/cart/items # Add to cart +PUT /api/cart/items/:id # Update quantity +DELETE /api/cart/items/:id # Remove item +POST /api/cart/coupon # Apply / remove coupon + +POST /api/checkout # Create order from cart +POST /api/checkout/guest # Guest checkout +GET /api/orders/:id # Order detail (guest by token) +``` + +### Admin / Management + +```yaml +GET /api/admin/products # List products +POST /api/admin/products # Create product +PUT /api/admin/products/:id # Update +DELETE /api/admin/products/:id # Delete + +GET /api/admin/categories # Categories +POST /api/admin/categories # Create +PUT /api/admin/categories/:id # Update +DELETE /api/admin/categories/:id # Delete + +GET /api/admin/orders # Orders (filtered) +GET /api/admin/orders/:id # Order detail +PUT /api/admin/orders/:id/status +POST /api/admin/orders/:id/refund + +GET /api/admin/inventory # Inventory snapshot +PUT /api/admin/inventory/:id # Update stock +POST /api/admin/inventory/adjust # Adjustment with reason + +GET /api/admin/discounts # Discount codes +POST /api/admin/discounts # Create +PUT /api/admin/discounts/:id # Update +``` + +## Payment Integration + +### Stripe Pattern + +```javascript +const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); + +async function createPaymentIntent(order) { + const intent = await stripe.paymentIntents.create({ + amount: Math.round(order.total * 100), // cents + currency: order.currency.toLowerCase(), + metadata: { orderId: order.id }, + receipt_email: order.email + }); + return { clientSecret: intent.client_secret, paymentIntentId: intent.id }; +} + +async function handleWebhook(signature, payload) { + const event = stripe.webhooks.constructEvent( + payload, signature, process.env.STRIPE_WEBHOOK_SECRET + ); + switch (event.type) { + case 'payment_intent.succeeded': + await markOrderPaid(event.data.object.metadata.orderId); + break; + case 'payment_intent.payment_failed': + await markOrderFailed(event.data.object.metadata.orderId); + break; + } +} +``` + +### PayPal Pattern + +```javascript +const paypal = require('@paypal/checkout-server-sdk'); + +async function createPayPalOrder(order) { + const request = new paypal.orders.OrdersCreateRequest(); + request.requestBody({ + intent: 'CAPTURE', + purchase_units: [{ + amount: { + currency_code: order.currency, + value: order.total.toFixed(2) + }, + reference_id: order.id + }] + }); + const response = await client.execute(request); + return response.result; +} + +async function capturePayPalOrder(orderId) { + const request = new paypal.orders.OrdersCaptureRequest(orderId); + const response = await client.execute(request); + return response.result; +} +``` + +## Inventory Safety + +### Stock Deduction at Checkout + +```javascript +async function deductStock(db, orderId) { + await db.transaction(async (trx) => { + for (const item of orderItems) { + const table = item.variant_id ? 'product_variants' : 'products'; + const idField = item.variant_id ? 'id' : 'id'; + const idValue = item.variant_id || item.product_id; + + const result = await trx(table) + .where({ id: idValue, quantity: db.raw('quantity >= ?', [item.quantity]) }) + .decrement('quantity', item.quantity); + + if (result === 0) throw new Error(`Insufficient stock for SKU ${item.sku}`); + + await trx('inventory_transactions').insert({ + product_id: item.product_id, + variant_id: item.variant_id, + quantity: -item.quantity, + type: 'sale', + reference: orderId + }); + } + }); +} +``` + +### Low-Stock Alert Query + +```sql +SELECT id, sku, name, quantity +FROM products +WHERE quantity <= ? AND is_active = 1; + +-- For variants +SELECT v.id, v.sku, p.name || ' - ' || v.name AS full_name, v.quantity +FROM product_variants v +JOIN products p ON p.id = v.product_id +WHERE v.quantity <= ? AND v.is_active = 1; +``` + +## Discount Logic + +```javascript +function applyDiscount(cart, code) { + if (!code.is_active) throw new Error('Code inactive'); + if (code.starts_at && now < code.starts_at) throw new Error('Not started'); + if (code.expires_at && now > code.expires_at) throw new Error('Expired'); + if (code.usage_limit && code.used_count >= code.usage_limit) throw new Error('Limit reached'); + if (cart.subtotal < code.min_order_amount) throw new Error('Minimum order not met'); + + let discount = 0; + if (code.type === 'percentage') { + discount = cart.subtotal * (code.value / 100); + } else if (code.type === 'fixed') { + discount = Math.min(code.value, cart.subtotal); + } else if (code.type === 'free_shipping') { + discount = cart.shipping; + } + + if (code.max_discount) discount = Math.min(discount, code.max_discount); + return discount; +} +``` + +## Email Templates + +### Order Confirmation + +```html +

Thank you for your order!

+

Order #{{order_number}}

+ +

Subtotal: {{currency}}{{subtotal}}

+

Shipping: {{currency}}{{shipping}}

+

Discount: -{{currency}}{{discount}}

+

Total: {{currency}}{{total}}

+

We'll email tracking info once shipped.

+``` + +### Shipping Notification + +```html +

Your order has shipped!

+

Order #{{order_number}}

+

Carrier: {{carrier}}

+

Tracking: {{tracking_number}}

+``` + +## Security Checklist + +- [ ] Never store credit card numbers +- [ ] Use PCI-compliant payment providers only +- [ ] CSRF protection on all state-changing cart / checkout endpoints +- [ ] Rate-limit checkout attempts (e.g. 5/minute per IP) +- [ ] Validate all discount constraints server-side +- [ ] Inventory deduction inside atomic transaction +- [ ] Webhook signatures verified before acting +- [ ] Order totals recalculated server-side; never trust client total + +## Integration Points + +| Concern | Options | +|---------|---------| +| Payment | Stripe, PayPal, YooKassa, Square | +| Shipping rates | ShipStation, EasyPost, carrier APIs | +| Tax calculation | TaxJar, Avalara, manual rules | +| Email | SendGrid, Mailgun, Resend | +| Search | Algolia, Meilisearch, Postgres full-text | +| Analytics | Google Analytics, Mixpanel, Plausible | +| CDN / images | Cloudflare R2, AWS S3, MinIO | + +## Handoff Protocol + +After implementation: +1. End-to-end test: add to cart → apply coupon → checkout → pay → order confirmation +2. Verify concurrent stock safety (simulate two checkouts for last item) +3. Test payment webhooks and status transitions +4. Validate email templates render correctly +5. Check discount edge cases (expired, over-limit, minimum not met) +6. Security audit via `@security-auditor` diff --git a/bun.lock b/bun.lock index 9536422..b376e50 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "zod": "^4.3.6", }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/bcrypt": "^6.0.0", "@types/bun": "^1.3.11", "typescript": "^5.3.0", @@ -43,6 +44,8 @@ "@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@1.0.11", "", { "dependencies": { "detect-libc": "^2.0.0", "https-proxy-agent": "^5.0.0", "make-dir": "^3.1.0", "node-fetch": "^2.6.7", "nopt": "^5.0.0", "npmlog": "^5.0.1", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.11" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ=="], + "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], + "@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], @@ -97,6 +100,8 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "gauge": ["gauge@3.0.2", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.2", "console-control-strings": "^1.0.0", "has-unicode": "^2.0.1", "object-assign": "^4.1.1", "signal-exit": "^3.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.2" } }, "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q=="], "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -151,6 +156,10 @@ "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], diff --git a/package.json b/package.json index 20fac42..3a2b272 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/bcrypt": "^6.0.0", "@types/bun": "^1.3.11", "typescript": "^5.3.0" diff --git a/tests/package-lock.json b/tests/package-lock.json index acafab9..8e9a741 100644 --- a/tests/package-lock.json +++ b/tests/package-lock.json @@ -1,6 +1,60 @@ { "name": "tests", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "name": "tests", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "playwright": "^1.60.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } } diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..fbb2091 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,15 @@ +{ + "name": "tests", + "version": "1.0.0", + "main": "capture-catalog.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "playwright": "^1.60.0" + } +} diff --git a/tests/scripts/capture-screenshots.js b/tests/scripts/capture-screenshots.js new file mode 100644 index 0000000..7a1dfd5 --- /dev/null +++ b/tests/scripts/capture-screenshots.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +async function runScreenshotCapture(mode = 'current') { + console.log(`📸 Starting Screenshot Capture (${mode})...`); + + // Get target URL from environment variable or use default + const targetUrl = process.env.TARGET_URL || 'http://host.docker.internal:8080'; + const catalogUrl = `${targetUrl}/catalog.html`; + + console.log(`📍 Target URL: ${catalogUrl}`); + + // Create screenshots directory based on mode + const screenshotsDir = path.join(__dirname, `../visual/${mode}`); + if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir, { recursive: true }); + } + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 } + }); + const page = await context.newPage(); + + try { + // Navigate to the catalog page + console.log('📍 Navigating to catalog page'); + await page.goto(catalogUrl, { waitUntil: 'networkidle', timeout: 30000 }); + + // Take full page screenshot + console.log('📸 Taking full page screenshot...'); + await page.screenshot({ + path: path.join(screenshotsDir, 'catalog-full-page.png'), + fullPage: true + }); + + // Scroll and capture each major section + console.log('📷 Capturing Featured Hero Banner...'); + const header = await page.locator('header').first(); + if (await header.isVisible()) { + await header.screenshot({ + path: path.join(screenshotsDir, 'catalog-hero-banner.png') + }); + } else { + console.log('⚠️ Hero banner not found'); + } + + console.log('📷 Capturing Quick Categories Row...'); + const categoriesSection = await page.locator('#quick-categories'); + if (await categoriesSection.isVisible()) { + await categoriesSection.screenshot({ + path: path.join(screenshotsDir, 'catalog-quick-categories.png') + }); + } else { + console.log('⚠️ Quick categories section not found'); + } + + console.log('📷 Capturing "¿Por qué comprar con nosotros?" section...'); + const whyBuySection = await page.locator('section.bg-primary'); + if (await whyBuySection.isVisible()) { + await whyBuySection.screenshot({ + path: path.join(screenshotsDir, 'catalog-why-buy.png') + }); + } else { + console.log('⚠️ "¿Por qué comprar con nosotros?" section not found'); + } + + console.log('📷 Capturing Catalog Grid...'); + const catalogGrid = await page.locator('.container.py-5:has-text("Catálogo")'); + if (await catalogGrid.isVisible()) { + await catalogGrid.screenshot({ + path: path.join(screenshotsDir, 'catalog-grid.png') + }); + } else { + console.log('⚠️ Catalog grid section not found'); + } + + console.log('📷 Capturing "¿Cómo funciona?" steps section...'); + const howItWorksSection = await page.locator('section.bg-light'); + if (await howItWorksSection.isVisible()) { + await howItWorksSection.screenshot({ + path: path.join(screenshotsDir, 'catalog-how-it-works.png') + }); + } else { + console.log('⚠️ "¿Cómo funciona?" section not found'); + } + + console.log('📷 Capturing CTA form section...'); + const ctaSection = await page.locator('section.bg-primary:has-text("Reserve su propiedad")'); + if (await ctaSection.isVisible()) { + await ctaSection.screenshot({ + path: path.join(screenshotsDir, 'catalog-cta-form.png') + }); + } else { + console.log('⚠️ CTA form section not found'); + } + + console.log('📷 Capturing FAQ accordion...'); + const faqSection = await page.locator('section.bg-white:has-text("Preguntas frecuentes")'); + if (await faqSection.isVisible()) { + await faqSection.screenshot({ + path: path.join(screenshotsDir, 'catalog-faq.png') + }); + } else { + console.log('⚠️ FAQ section not found'); + } + + // Test navigation menu links + console.log('🧭 Testing navigation menu links...'); + + // Click "Inicio" link + console.log('🔗 Clicking "Inicio" link...'); + const inicioLink = await page.locator('a.nav-link[href="/"]').first(); + if (await inicioLink.isVisible()) { + await inicioLink.click(); + await page.waitForTimeout(2000); // Wait for navigation + + // Verify we're on the homepage + const currentUrl = page.url(); + console.log(`📍 Current URL after clicking "Inicio": ${currentUrl}`); + + if (currentUrl.includes('localhost:8080') || currentUrl === targetUrl + '/') { + console.log('✅ Navigation to homepage successful'); + } else { + console.log('❌ Navigation to homepage failed'); + } + + // Go back to catalog page for further testing + await page.goto(catalogUrl, { waitUntil: 'networkidle' }); + await page.waitForTimeout(1000); + } else { + console.log('⚠️ "Inicio" link not found'); + } + + // Report findings + console.log('\n📋 SCREENSHOT CAPTURE SUMMARY'); + console.log('============================='); + console.log(`✅ Full page screenshot captured (${mode})`); + console.log(`✅ Hero banner section screenshot captured (${mode})`); + console.log(`✅ Quick categories section screenshot captured (${mode})`); + console.log(`✅ "¿Por qué comprar con nosotros?" section screenshot captured (${mode})`); + console.log(`✅ Catalog grid screenshot captured (${mode})`); + console.log(`✅ "¿Cómo funciona?" section screenshot captured (${mode})`); + console.log(`✅ CTA form section screenshot captured (${mode})`); + console.log(`✅ FAQ section screenshot captured (${mode})`); + console.log(`✅ Navigation test completed (${mode})`); + + console.log(`\n📁 Screenshots saved to: ${screenshotsDir}`); + + } catch (error) { + console.error('❌ Error during screenshot capture:', error); + } finally { + await browser.close(); + console.log('\n🏁 Screenshot capture completed'); + } +} + +// Get mode from command line arguments +const mode = process.argv[2] || 'current'; + +// Run the screenshot capture +runScreenshotCapture(mode); \ No newline at end of file diff --git a/tests/scripts/catalog-capture.js b/tests/scripts/catalog-capture.js new file mode 100644 index 0000000..705c73d --- /dev/null +++ b/tests/scripts/catalog-capture.js @@ -0,0 +1,190 @@ +#!/usr/bin/env node + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +async function runCatalogTest() { + console.log('🔍 Starting Catalog Page Visual Test...'); + + // Get target URL from environment variable or use default + const targetUrl = process.env.TARGET_URL || 'http://host.docker.internal:8080'; + const catalogUrl = `${targetUrl}/catalog.html`; + + console.log(`📍 Target URL: ${catalogUrl}`); + + // Create screenshots directory if it doesn't exist + const screenshotsDir = path.join(__dirname, '../screenshots'); + if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir, { recursive: true }); + } + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 } + }); + const page = await context.newPage(); + + try { + // Navigate to the catalog page + console.log('📍 Navigating to catalog page'); + await page.goto(catalogUrl, { waitUntil: 'networkidle', timeout: 30000 }); + + // Take full page screenshot + console.log('📸 Taking full page screenshot...'); + await page.screenshot({ + path: path.join(screenshotsDir, 'catalog-full-page.png'), + fullPage: true + }); + + // Scroll and capture each major section + console.log('📷 Capturing Featured Hero Banner...'); + const header = await page.locator('header').first(); + if (await header.isVisible()) { + await header.screenshot({ + path: path.join(screenshotsDir, 'catalog-hero-banner.png') + }); + } else { + console.log('⚠️ Hero banner not found'); + } + + console.log('📷 Capturing Quick Categories Row...'); + const categoriesSection = await page.locator('#quick-categories'); + if (await categoriesSection.isVisible()) { + await categoriesSection.screenshot({ + path: path.join(screenshotsDir, 'catalog-quick-categories.png') + }); + } else { + console.log('⚠️ Quick categories section not found'); + } + + console.log('📷 Capturing "¿Por qué comprar con nosotros?" section...'); + const whyBuySection = await page.locator('section.bg-primary'); + if (await whyBuySection.isVisible()) { + await whyBuySection.screenshot({ + path: path.join(screenshotsDir, 'catalog-why-buy.png') + }); + } else { + console.log('⚠️ "¿Por qué comprar con nosotros?" section not found'); + } + + console.log('📷 Capturing Catalog Grid...'); + const catalogGrid = await page.locator('.container.py-5:has-text("Catálogo")'); + if (await catalogGrid.isVisible()) { + await catalogGrid.screenshot({ + path: path.join(screenshotsDir, 'catalog-grid.png') + }); + } else { + console.log('⚠️ Catalog grid section not found'); + } + + console.log('📷 Capturing "¿Cómo funciona?" steps section...'); + const howItWorksSection = await page.locator('section.bg-light'); + if (await howItWorksSection.isVisible()) { + await howItWorksSection.screenshot({ + path: path.join(screenshotsDir, 'catalog-how-it-works.png') + }); + } else { + console.log('⚠️ "¿Cómo funciona?" section not found'); + } + + console.log('📷 Capturing CTA form section...'); + const ctaSection = await page.locator('section.bg-primary:has-text("Reserve su propiedad")'); + if (await ctaSection.isVisible()) { + await ctaSection.screenshot({ + path: path.join(screenshotsDir, 'catalog-cta-form.png') + }); + } else { + console.log('⚠️ CTA form section not found'); + } + + console.log('📷 Capturing FAQ accordion...'); + const faqSection = await page.locator('section.bg-white:has-text("Preguntas frecuentes")'); + if (await faqSection.isVisible()) { + await faqSection.screenshot({ + path: path.join(screenshotsDir, 'catalog-faq.png') + }); + } else { + console.log('⚠️ FAQ section not found'); + } + + // Test navigation menu links + console.log('🧭 Testing navigation menu links...'); + + // Click "Inicio" link + console.log('🔗 Clicking "Inicio" link...'); + const inicioLink = await page.locator('a.nav-link[href="/"]').first(); + if (await inicioLink.isVisible()) { + await inicioLink.click(); + await page.waitForTimeout(2000); // Wait for navigation + + // Verify we're on the homepage + const currentUrl = page.url(); + console.log(`📍 Current URL after clicking "Inicio": ${currentUrl}`); + + if (currentUrl.includes('localhost:8080') || currentUrl === targetUrl + '/') { + console.log('✅ Navigation to homepage successful'); + } else { + console.log('❌ Navigation to homepage failed'); + } + + // Go back to catalog page for further testing + await page.goto(catalogUrl, { waitUntil: 'networkidle' }); + await page.waitForTimeout(1000); + } else { + console.log('⚠️ "Inicio" link not found'); + } + + // Report findings + console.log('\n📋 TEST RESULTS SUMMARY'); + console.log('======================'); + console.log('✅ Full page screenshot captured'); + console.log('✅ Hero banner section screenshot captured'); + console.log('✅ Quick categories section screenshot captured'); + console.log('✅ "¿Por qué comprar con nosotros?" section screenshot captured'); + console.log('✅ Catalog grid screenshot captured'); + console.log('✅ "¿Cómo funciona?" section screenshot captured'); + console.log('✅ CTA form section screenshot captured'); + console.log('✅ FAQ section screenshot captured'); + console.log('✅ Navigation test completed'); + + console.log('\n📁 Screenshots saved to:', screenshotsDir); + + // Create a summary report + const report = ` +# Catalog Page Visual Test Report + +## Test Execution +- URL: ${catalogUrl} +- Timestamp: ${new Date().toISOString()} + +## Screenshots Captured +1. Full page screenshot +2. Hero banner section +3. Quick categories row +4. "¿Por qué comprar con nosotros?" section +5. Catalog grid +6. "¿Cómo funciona?" steps section +7. CTA form section +8. FAQ accordion + +## Navigation Test +- Clicking "Inicio" link: ${await inicioLink.isVisible() ? '✅ Passed' : '⚠️ Not found'} + +## Status +All tests completed successfully. +`; + + fs.writeFileSync(path.join(screenshotsDir, 'catalog-test-report.md'), report); + console.log('\n📝 Test report saved to:', path.join(screenshotsDir, 'catalog-test-report.md')); + + } catch (error) { + console.error('❌ Error during test execution:', error); + } finally { + await browser.close(); + console.log('\n🏁 Test execution completed'); + } +} + +// Run the test +runCatalogTest(); \ No newline at end of file diff --git a/tests/scripts/console-error-monitor-standalone.js b/tests/scripts/console-error-monitor-standalone.js new file mode 100644 index 0000000..09607c2 --- /dev/null +++ b/tests/scripts/console-error-monitor-standalone.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +async function runConsoleErrorMonitor() { + console.log('🔍 Starting Console Error Monitor...'); + + // Get target URL from environment variable or use default + const targetUrl = process.env.TARGET_URL || 'http://host.docker.internal:8080'; + const catalogUrl = `${targetUrl}/catalog.html`; + + console.log(`📍 Target URL: ${catalogUrl}`); + + // Create reports directory if it doesn't exist + const reportsDir = process.env.REPORTS_DIR || path.join(__dirname, '../reports'); + if (!fs.existsSync(reportsDir)) { + fs.mkdirSync(reportsDir, { recursive: true }); + } + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 } + }); + + // Capture console messages + const consoleMessages = []; + context.on('console', msg => { + consoleMessages.push({ + type: msg.type(), + text: msg.text(), + location: msg.location(), + timestamp: new Date().toISOString() + }); + console.log(`_CONSOLE: ${msg.type()} - ${msg.text()}`); + }); + + // Capture page errors + const pageErrors = []; + context.on('pageerror', error => { + pageErrors.push({ + message: error.message, + stack: error.stack, + timestamp: new Date().toISOString() + }); + console.log(`_PAGE_ERROR: ${error.message}`); + }); + + const page = await context.newPage(); + + try { + // Navigate to the catalog page + console.log('📍 Navigating to catalog page'); + await page.goto(catalogUrl, { waitUntil: 'networkidle', timeout: 30000 }); + + // Wait a bit for any JavaScript to execute + await page.waitForTimeout(5000); + + // Check for specific sections + console.log('📍 Checking page sections...'); + + // Check if main sections are present + const sections = [ + { name: 'Hero Banner', selector: 'header' }, + { name: 'Quick Categories', selector: '#quick-categories' }, + { name: 'Catalog Grid', selector: '.container.py-5:has-text("Catálogo")' }, + { name: 'Why Buy Section', selector: 'section.bg-primary' }, + { name: 'How It Works', selector: 'section.bg-light' }, + { name: 'CTA Form', selector: 'section.bg-primary:has-text("Reserve su propiedad")' }, + { name: 'FAQ Section', selector: 'section.bg-white:has-text("Preguntas frecuentes")' } + ]; + + const sectionStatus = {}; + for (const section of sections) { + const element = await page.locator(section.selector); + sectionStatus[section.name] = await element.isVisible() ? '✅ Visible' : '❌ Not Found'; + console.log(` ${section.name}: ${sectionStatus[section.name]}`); + } + + // Test navigation menu links + console.log('🧭 Testing navigation menu links...'); + + // Click "Inicio" link + console.log('🔗 Clicking "Inicio" link...'); + const inicioLink = await page.locator('a.nav-link[href="/"]').first(); + if (await inicioLink.isVisible()) { + await inicioLink.click(); + await page.waitForTimeout(2000); // Wait for navigation + + // Verify we're on the homepage + const currentUrl = page.url(); + console.log(`📍 Current URL after clicking "Inicio": ${currentUrl}`); + + if (currentUrl.includes('localhost:8080') || currentUrl === targetUrl + '/') { + console.log('✅ Navigation to homepage successful'); + } else { + console.log('❌ Navigation to homepage failed'); + } + } else { + console.log('⚠️ "Inicio" link not found'); + } + + // Report findings + console.log('\n📋 CONSOLE ERROR MONITOR REPORT'); + console.log('=============================='); + + // Console errors + if (consoleMessages.length > 0) { + console.log('\n📝 Console Messages:'); + consoleMessages.forEach(msg => { + console.log(` ${msg.type.toUpperCase()}: ${msg.text}`); + }); + } else { + console.log('\n✅ No console messages captured'); + } + + // Page errors + if (pageErrors.length > 0) { + console.log('\n❌ Page Errors:'); + pageErrors.forEach(error => { + console.log(` ${error.message}`); + }); + } else { + console.log('\n✅ No page errors detected'); + } + + // Section status + console.log('\n📍 Section Status:'); + Object.entries(sectionStatus).forEach(([name, status]) => { + console.log(` ${name}: ${status}`); + }); + + // Create a detailed report + const report = { + timestamp: new Date().toISOString(), + targetUrl: catalogUrl, + consoleMessages, + pageErrors, + sectionStatus, + summary: { + totalConsoleMessages: consoleMessages.length, + totalPageErrors: pageErrors.length, + sectionsFound: Object.values(sectionStatus).filter(status => status.includes('Visible')).length, + sectionsTotal: Object.keys(sectionStatus).length + } + }; + + // Save report to JSON file + const reportPath = path.join(reportsDir, 'console-error-report.json'); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + console.log(`\n📝 Report saved to: ${reportPath}`); + + // Save report as markdown too + const markdownReport = ` +# Console Error Monitor Report + +## Test Execution +- URL: ${catalogUrl} +- Timestamp: ${new Date().toISOString()} + +## Summary +- Console Messages: ${consoleMessages.length} +- Page Errors: ${pageErrors.length} +- Sections Found: ${report.summary.sectionsFound}/${report.summary.sectionsTotal} + +## Console Messages +${consoleMessages.map(msg => `- ${msg.type.toUpperCase()}: ${msg.text}`).join('\n') || '✅ No console messages'} + +## Page Errors +${pageErrors.map(error => `- ${error.message}`).join('\n') || '✅ No page errors'} + +## Section Status +${Object.entries(sectionStatus).map(([name, status]) => `- ${name}: ${status}`).join('\n')} + +## Status +${pageErrors.length > 0 ? '❌ Issues detected' : '✅ All tests passed'} +`; + + const markdownReportPath = path.join(reportsDir, 'console-error-report.md'); + fs.writeFileSync(markdownReportPath, markdownReport); + console.log(`\n📝 Markdown report saved to: ${markdownReportPath}`); + + // Exit with appropriate code + if (pageErrors.length > 0) { + console.log('\n⚠️ Issues were detected during testing'); + process.exit(1); + } else { + console.log('\n✅ All tests passed successfully'); + process.exit(0); + } + + } catch (error) { + console.error('❌ Error during console error monitoring:', error); + process.exit(1); + } finally { + await browser.close(); + console.log('\n🏁 Console error monitoring completed'); + } +} + +// Run the console error monitor +runConsoleErrorMonitor(); \ No newline at end of file