feat(tests): add visual testing scripts with Playwright + domain skills
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -36,6 +36,11 @@ npm-debug.log*
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Playwright test artifacts
|
||||
test-results/
|
||||
tests/screenshots/
|
||||
tests/final-screenshots/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
20
.kilo/package-lock.json
generated
20
.kilo/package-lock.json
generated
@@ -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
400
.kilo/skills/blog/SKILL.md
Normal 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`
|
||||
547
.kilo/skills/booking/SKILL.md
Normal file
547
.kilo/skills/booking/SKILL.md
Normal 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`
|
||||
489
.kilo/skills/ecommerce/SKILL.md
Normal file
489
.kilo/skills/ecommerce/SKILL.md
Normal 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`
|
||||
9
bun.lock
9
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=="],
|
||||
|
||||
@@ -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"
|
||||
|
||||
56
tests/package-lock.json
generated
56
tests/package-lock.json
generated
@@ -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
15
tests/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
165
tests/scripts/capture-screenshots.js
Normal file
165
tests/scripts/capture-screenshots.js
Normal 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);
|
||||
190
tests/scripts/catalog-capture.js
Normal file
190
tests/scripts/catalog-capture.js
Normal 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();
|
||||
203
tests/scripts/console-error-monitor-standalone.js
Normal file
203
tests/scripts/console-error-monitor-standalone.js
Normal 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();
|
||||
Reference in New Issue
Block a user