feat(tests): add visual testing scripts with Playwright + domain skills

This commit is contained in:
APAW Agent Sync
2026-05-13 21:27:27 +01:00
parent 436e0cbf5a
commit b7afaadb96
12 changed files with 2089 additions and 11 deletions

5
.gitignore vendored
View File

@@ -36,6 +36,11 @@ npm-debug.log*
# Test coverage
coverage/
# Playwright test artifacts
test-results/
tests/screenshots/
tests/final-screenshots/
# TypeScript
*.tsbuildinfo

View File

@@ -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"

400
.kilo/skills/blog/SKILL.md Normal file
View File

@@ -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 `<pre><code class="hljs ${language}">${highlighted}</code></pre>`;
},
image(href, title, alt) {
return `<figure><img src="${href}" alt="${alt}" loading="lazy"><figcaption>${alt}</figcaption></figure>`;
}
}
});
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
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>{{siteUrl}}/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>
{{#posts}}
<url>
<loc>{{siteUrl}}/posts/{{slug}}</loc>
<lastmod>{{updated_at}}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
{{/posts}}
</urlset>
```
## 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`

View File

@@ -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: `
<h1>Your booking is confirmed!</h1>
<p>Booking #${booking.booking_number}</p>
<ul>
<li>Service: ${service.name}</li>
<li>Staff: ${staff.name}</li>
<li>Date: ${formatDate(booking.booking_date)}</li>
<li>Time: ${booking.start_time} - ${booking.end_time}</li>
<li>Price: ${formatCurrency(booking.total)}</li>
</ul>
<p><a href="${config.siteUrl}/booking/${booking.id}">Manage booking</a></p>
`
};
}
```
### 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`

View File

@@ -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
<h1>Thank you for your order!</h1>
<p>Order #{{order_number}}</p>
<ul>
{{#items}}
<li>{{name}} ({{sku}}) × {{quantity}} — {{currency}}{{total}}</li>
{{/items}}
</ul>
<p>Subtotal: {{currency}}{{subtotal}}</p>
<p>Shipping: {{currency}}{{shipping}}</p>
<p>Discount: -{{currency}}{{discount}}</p>
<p><strong>Total: {{currency}}{{total}}</strong></p>
<p>We'll email tracking info once shipped.</p>
```
### Shipping Notification
```html
<h1>Your order has shipped!</h1>
<p>Order #{{order_number}}</p>
<p>Carrier: {{carrier}}</p>
<p>Tracking: <a href="{{tracking_url}}">{{tracking_number}}</a></p>
```
## 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`

View File

@@ -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=="],

View File

@@ -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"

View File

@@ -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"
}
}
}
}

15
tests/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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();