Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b57bfee4 | ||
|
|
638a59018a | ||
|
|
0161924088 | ||
|
|
82414e8a93 |
+13
-1
@@ -26,7 +26,19 @@ The public author label of a comment: a snapshot of `users.fullName` taken at su
|
||||
|
||||
## الاشتراك قريبًا / Coming soon
|
||||
|
||||
The public state of the subscribe flow until Stripe keys exist: `POST /api/subscribe` returns `{ comingSoon: true }` and no `url`; the UI renders a disabled «قريباً» button. No VIP is granted without a Stripe-verified session — neither via the subscribe API nor via `/payment-success` (free users are redirected to `/subscription`).
|
||||
The public state of the subscribe flow while billing is disabled. From the first paint the UI renders a disabled «قريباً» button (when `LAUNCH_MODE` disables billing there is no active «اشترك الآن» that fails late); if a request is still attempted while Stripe keys are absent, `POST /api/subscribe` returns `{ comingSoon: true }` with no `url`, and the button shows an in-page retry state — **not a redirect** to `/payment-success` and no VIP is minted there either way. No VIP is granted without a Stripe-verified session.
|
||||
|
||||
## الوصول المبكر المجاني / Free early access
|
||||
|
||||
The launch policy (`LAUNCH_MODE=free_early_access`, the compose default): every approved **published** post is readable and its approved files downloadable without an account, regardless of the post's intended tier. Billing stays off; nothing is charged or auto-subscribed. The mode has no date expiry — the ~two-week checkpoint is a review date, not a cutoff.
|
||||
|
||||
## الطبقة المقصودة / Intended tier
|
||||
|
||||
The post's `access` field (`free` | `vip`) — what the content is meant to be once billing is enabled. The launch never rewrites it: a VIP-designated post opened by early access keeps `access: 'vip'` so rollback is instant and cards/comparisons stay truthful.
|
||||
|
||||
## الوصول الفعلي / Effective access
|
||||
|
||||
The decision actually enforced at request time: `status: 'published'` **and** (`access: 'free'` **or** free early access). One policy (`src/lib/launch.ts`) feeds pages, `/api/posts/[slug]`, the file gate, and the llms exports, so visible labels and served bytes can't disagree. A draft is never readable in any mode.
|
||||
|
||||
## كتابة موثوقة / Trusted write
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Marketing-skills audit — anwarpy.ai (2026-10-10)
|
||||
|
||||
> Copy of the local audit of 2026-10-10 (source: agent workspace `marketing-skills-audit-findings.md`), committed for the launch PR. Items marked ❌ were fixed the same day in the launch/early-access branch; see `docs/launch/2026-10-10-launch-readiness.md` for per-item evidence.
|
||||
|
||||
Audited against installed playbooks: paywalls, signup, ai-seo, programmatic-seo, aso (`.agents/skills/`). Evidence: repo `web/src` (file:line) + live production HTML. Verdicts: ✅ compliant · ⚠️ partial · ❌ gap.
|
||||
|
||||
## Paywalls (skill: paywalls v2.0)
|
||||
|
||||
**Current state — technically solid.** Gating = `users.role` (free/vip/admin); VIP body has field-level read ACL (`collections/Posts.ts:57-71`) + page-level select — never served to non-VIP. Teaser paragraphs required for VIP posts (`Posts.ts:39-51`) with fade-out mask; paywall box on locked posts has headline, price-in-CTA («اشترك بـ $10 شهرياً», `lib/format.ts:11`), objection line («تلغي في أي وقت»), login secondary. Per-file byte gate on attachments (`/api/files/[id]`). Stripe: Checkout → `/payment-success` re-verification + webhook activation; deletion webhook downgrades. No dark patterns anywhere.
|
||||
|
||||
**Gaps**
|
||||
- ❌ Value demonstration: locked H2 titles never shown (`posts/[slug]/page.tsx:202` extracts TOC only when unlocked) — paywall promises «الأقسام العملية» generically.
|
||||
- ❌ Social proof absent on paywall and subscription (subscription shows guide counts only). No fabricated quotes allowed; needs owner-provided testimonial or real aggregate.
|
||||
- ❌ No FAQ link or free-escape-hatch inside the paywall box (FAQ lives only on /subscription).
|
||||
- ❌ Coming-soon discovered after failed click (`subscription/subscribe-button.tsx:16-27`): glossary says disabled «قريباً» button; actual renders active «اشترك الآن» and reveals coming-soon only after POST fails.
|
||||
- ❌ No measurement (paywall impression/CTA events) — blocked on analytics-tooling owner decision.
|
||||
- ⚠️ Pricing: monthly $10 only; skill suggests annual toggle — owner policy.
|
||||
- 🧹 Dead code: `styles/post-locked.css`, `styles/post-unlocked.css` imported nowhere (live styles in globals.css:168-190, design.css:271-287).
|
||||
|
||||
## Signup (skill: signup v2.0)
|
||||
|
||||
**Current state.** 5 fields (الاسم required, email, password ≥8 with show-toggle + hint, تأكيد كلمة المرور, terms checkbox) + hidden `termsVersion`. Native constraint validation with Arabic custom messages; paste allowed; visible labels; single column mobile. Post-submit: auto-login → `/verify-email` (can explore unverified; only commenting gated, 403 with Arabic copy) → `/onboarding` preserving `anwarpi:signup-next`. `safeNext` redirect hygiene. Rate limits: 5/h signup, 10/15min login per-IP (`middleware.ts`), Payload lockout 5/15min (admin exempt, PBKDF2 timing-equalizer).
|
||||
|
||||
**Gaps**
|
||||
- ❌ **Verification email is never sent at signup** — `sendVerificationEmail` has a single call site (`api/auth/resend-verification/route.ts`); `Users.ts` has no afterChange hook. The verify page claims «أرسلنا رابط التأكيد إلى:» falsely until the user clicks resend. Highest-impact fix.
|
||||
- ❌ 429 body mis-parsed: signup reads only `err?.errors?.[0]?.message` (`signup/page.tsx:44-47`), so middleware's `{error}` JSON is ignored → rate-limited users see «قد يكون البريد مستخدماً بالفعل» (wrong). Login shows one generic message for all failures (`login/page.tsx:29-31`). Retry-After never surfaced.
|
||||
- ❌ No spam-folder step and no change-email affordance on `/verify-email` (skill post-submit checklist).
|
||||
- ❌ Field count above skill's ≤3 guidance (confirm-password cuttable; name kept for comment display names).
|
||||
- ❌ No «مجاناً — بلا بطاقة ائتمانية» / privacy microcopy near CTA; no inline duplicate-email error with login link; no email-typo suggestion; static (not live) password hint.
|
||||
- ❌ Social auth absent (Google/Apple) — needs owner (OAuth apps).
|
||||
- ✅ Already compliant: single email field, no placeholder-only labels, value-story panel, terms timestamped (`termsAcceptedAt`).
|
||||
|
||||
## AI-SEO (skill: ai-seo v2.0)
|
||||
|
||||
**Current state — good SSR baseline.** All pages force-dynamic server components (content in initial HTML); `lang="ar" dir="rtl"`; self-canonical everywhere; title template; sitemap (published posts + lastmod, 8 static paths); robots with disallow list + sitemap pointer; WebSite JSON-LD (home) and Article JSON-LD (posts); answer-first seed content; public per-post JSON at `/api/posts/[slug]`.
|
||||
|
||||
**Gaps**
|
||||
- ❌ No `llms.txt` / `llms-full.txt`.
|
||||
- ❌ No named AI-crawler robots policy (implied `*` only); `/api` disallow hides the agent-readable post JSON.
|
||||
- ❌ Article pages emit **no og:image** — `generateMetadata` (`posts/[slug]/page.tsx:16-33`) defines `openGraph` which *replaces* the root OG object (no siteName/locale/images), though `post.cover.url` exists.
|
||||
- ❌ Draft leak: the metadata query lacks the `status: 'published'` filter the page query has → draft titles/deks render in metadata of 404 pages.
|
||||
- ❌ Article JSON-LD missing `image`, `dateModified`, `description`, `mainEntityOfPage`, `publisher`.
|
||||
- ❌ FAQPage schema absent despite real Q&A (`subscription/page.tsx:16-21` FAQ_ITEMS); BreadcrumbList absent despite breadcrumb UI (`posts/[slug]/page.tsx:179-183`); no Organization schema.
|
||||
- ❌ Lexical renderer has no `table` node case (~`page.tsx:373-387`) — comparison tables in content fall through to `<p>`.
|
||||
- ⚠️ Freshness: sitemap uses updatedAt but the page shows only publishedAt — add «حُدّث» when they differ.
|
||||
- ✅ Answer-first style present; single H1; h2 extraction; agent-accessible free JSON exists.
|
||||
|
||||
## Programmatic SEO (skill: programmatic-seo)
|
||||
|
||||
**Current state.** Indexable surface = `/`, `/articles`, `/posts/[slug]`, 7 static pages. All category/topic navigation is client-side DOM filtering on ONE URL (`articles-client.tsx:11-19, 62-70`) — clicks never update the URL; `?topic=` states share one canonical. `/catalog` permanently redirects to /articles; `catalog-filters.tsx` + `catalog-deck.tsx` exported nowhere (dead).
|
||||
|
||||
**Data available in Postgres** (Posts): title, slug, dek, category (6 values), level (3), access, status, publishedAt/updatedAt, readingMinutes, views, likes, cover, attachments. Public taxonomy already exists: `lib/topics.ts` — 4 topics (`agents` الوكلاء, `models` النماذج, `automation` الأتمتة, `foundations` الأساسيات) mapped from the 6 CMS categories via `topicsFor(post)` + `isTopic()`.
|
||||
|
||||
**Decision (owner-confirmed 2026-10-10): build 4 topic hub pages `/topics/[topic]`** — server-rendered directories with unique intros, counts, breadcrumbs + BreadcrumbList, sitemap entries, internal links from home/articles/post breadcrumbs. Later candidates (not now): topic×level personas (gate on ≥3 posts/combo), templates directory, glossary (needs new field). Comparisons/locations/translations: no data, N/A.
|
||||
|
||||
## ASO (transferable items only — site, not an app)
|
||||
|
||||
- ❌ Home title is brand-only «أنور بآي» — unknown brands need keywords: «أنور بآي — أدلة الذكاء الاصطناعي العملي بالعربية».
|
||||
- ❌ Only root + posts have descriptions; give /articles, /subscription, /about their own Arabic descriptions.
|
||||
- ❌ One static OG image for everything → per-post `cover` as og:image (same fix as ai-seo list).
|
||||
- ❌ Surface «حُدّث» freshness date on articles.
|
||||
- ✅ Localization correct (ar locale, RTL, og:locale). Ratings/screenshot fields: N/A.
|
||||
|
||||
## Needs owner (not in this pass)
|
||||
|
||||
1. Social auth providers (Google/Apple or magic links) — OAuth apps + keys.
|
||||
2. Analytics/experimentation tooling (paywall impressions, CTA CTR, form drop-off) — vendor + privacy stance; code ready once chosen.
|
||||
3. Annual plan / money-back guarantee — pricing policy.
|
||||
4. Testimonial content — must be real; nothing fabricated.
|
||||
5. Brand spelling inconsistency: site uses «أنور بآي» everywhere (title, og:site_name, JSON-LD); docs use «أنور بي آي». Pick one canonical spelling.
|
||||
6. Policy: allow free users to comment on VIP posts (currently hard-403) — easy conversion lever if allowed.
|
||||
7. Sitemap `limit: 500` with no pagination — fine at 13 posts; revisit at scale. /login + /signup kept in sitemap.
|
||||
8. `GLOSSARY.md` says free users are *redirected* from /payment-success; actual behavior is an in-page retry state — doc will be aligned to code (no redirect; no VIP minting either way).
|
||||
@@ -0,0 +1,347 @@
|
||||
# Anwarpy.ai launch readiness and developer handoff
|
||||
|
||||
Prepared for Ahmed and the developer team. Basis: public-site audit on 10 October 2026, refreshed membership/terms/privacy pages, Ahmed's confirmation that Stripe is unfinished, and the installed launch skill's readiness and site-QA framework.
|
||||
|
||||
## 1. Decision
|
||||
|
||||
**Paid/VIP launch: NO-GO at present. Free early-access launch: CONDITIONAL GO after the launch-blocking checks below pass.**
|
||||
|
||||
Stripe does not have to delay a useful free content launch. Launch the complete experience of reading useful Arabic posts and approved downloads first. Run an approximately two-week learning period; treat its end as a review checkpoint, not an automatic paywall date. Continue free early access if Stripe or access control remains unready. Do not advertise an exact paid-start date until approved.
|
||||
|
||||
This document is a proposed implementation specification, not evidence that fixes have been applied. No website, Stripe account, source notebook or production database was modified during preparation. Ticket owners are recommended roles; individual assignees and calendar dates remain to be assigned.
|
||||
|
||||
### User decisions incorporated
|
||||
|
||||
- Blog content originates primarily in AppFlowy and Obsidian notes.
|
||||
- An AI agent will help turn approved notes into blog posts.
|
||||
- Stripe integration is unfinished.
|
||||
- Desired initial period: about two weeks with free access, followed by VIP gating after Stripe is ready.
|
||||
|
||||
### Important correction to the earlier audit
|
||||
|
||||
Short notes are a legitimate content type. A 150–300-word note need not become a long tutorial to be useful. The problem is a mismatch between short content and labels promising a complete implementation or nine/ten minutes of reading. Add explicit content types and honest estimates. Expand only posts intended to be implementation guides, not every note.
|
||||
|
||||
## 2. Evidence and status
|
||||
|
||||
Confirmed in the earlier public audit:
|
||||
- All 22 unique sitemap URLs returned 200, including 14 posts: eight public full posts and six VIP previews.
|
||||
- Public text and membership price were present in initial HTML.
|
||||
- Canonicals, Arabic/RTL settings, sitemap and robots.txt exist.
|
||||
- Article and WebSite JSON-LD were observed in initial HTML. Schema is not absent.
|
||||
- Homepage/library titles are generic; eight non-post pages share a generic description.
|
||||
- Login and signup are in the sitemap; no noindex meta was observed in their initial HTML.
|
||||
- www serves a 200 homepage while canonical points to the apex domain.
|
||||
- First-agent and RAG article bodies were approximately 165 and 154 whitespace-delimited words, despite nine/ten-minute reading estimates.
|
||||
- No n8n mention was found in public text of the 14 posts. Paid bodies were not inspected.
|
||||
- Some free posts display download controls, while the paid comparison frames working files as a membership benefit. Actual download behavior was not tested.
|
||||
|
||||
Rechecked for this handoff:
|
||||
- /subscription still advertises $10 monthly, subscription CTAs and Stripe payment.
|
||||
- /terms still declares itself a prelaunch draft and calls the price experimental.
|
||||
- /privacy still contains prelaunch placeholders for actual providers, retention and effective dates.
|
||||
|
||||
Not verified; developer must test:
|
||||
- Full reading/download access when launch-free mode is applied.
|
||||
- Account signup, email verification, password reset, save/comment flows.
|
||||
- Mobile rendering, RTL behavior, browser interactions, accessible controls.
|
||||
- Checkout, webhooks, entitlements, cancellation and renewal.
|
||||
- Paid-body protection in APIs, page source, hydration payloads, search, feeds, assets and caches.
|
||||
- Restore procedures, monitoring, deployed security controls.
|
||||
- Search Console index status, field performance, analytics and actual AI citations.
|
||||
|
||||
The audit browser failed to start and PageSpeed returned 429. These failures do not establish website defects. Public metadata checks do not certify application behavior.
|
||||
|
||||
## 3. Recommended launch policy
|
||||
|
||||
### Free launch mode
|
||||
|
||||
Recommended initial settings, to adapt to the actual application rather than copy as existing field names:
|
||||
|
||||
```yaml
|
||||
launch_mode: free_early_access
|
||||
billing_enabled: false
|
||||
vip_enforcement_enabled: false
|
||||
review_checkpoint: approximately_two_weeks_after_actual_launch
|
||||
paid_transition: manual_owner_approval_only
|
||||
reading_requires_account: false
|
||||
newsletter_opt_in_required: true
|
||||
```
|
||||
|
||||
Open approved published content, including posts designated for future VIP access, during the early-access period. Keep drafts, private source notes, internal comments and unsafe attachments private. Guest reading should work without an account; accounts can remain optional for tested features. If signup is not ready, disable its CTA instead of blocking reading.
|
||||
|
||||
**Do not create Stripe trial subscriptions to implement this temporary editorial launch.** A site-level free-access policy is simpler and avoids accidentally collecting cards, issuing charges or making every reader a billing customer.
|
||||
|
||||
### Preserve editorial classification
|
||||
|
||||
Store the intended tier independently from effective access. A post can be designated VIP for the future while temporarily open now. Recommended conceptual fields:
|
||||
|
||||
```yaml
|
||||
publication_status: draft | review | published | archived
|
||||
content_type: note | tutorial | case_study | comparison
|
||||
intended_tier: free | vip
|
||||
early_access_eligible: true | false
|
||||
source_reference: private_traceability_record
|
||||
author_id: approved_real_author
|
||||
review_status: pending | approved
|
||||
```
|
||||
|
||||
The global launch override should apply only to approved published posts and approved associated assets. Never rewrite every VIP post to a permanent free tier, grant permanent VIP user roles, or expose raw CMS records in a public launch feed.
|
||||
|
||||
### What happens after the two-week checkpoint?
|
||||
|
||||
- If Stripe is not ready: continue free mode, update messaging and set a new review checkpoint.
|
||||
- If Stripe is ready but tests fail: continue free mode; repair failures before gating.
|
||||
- If tests pass: notify readers, specify the future gated set, have Ahmed approve the transition, then enable billing and gating in a controlled rollout.
|
||||
- Posts designated permanently free remain free.
|
||||
- Posts explicitly labeled as temporary VIP early-access may become previews. Preserve their URLs and public summaries.
|
||||
- Already downloaded public files cannot reliably be recalled. Do not promise otherwise.
|
||||
- Readers are never automatically subscribed or charged. Joining an update list is not consent to recurring billing.
|
||||
|
||||
Recommended trust-preserving alternative: keep the strongest launch tutorials free permanently and monetize new advanced workflows and files. This is an option for Ahmed to choose, not an assumed change to his requested VIP plan.
|
||||
|
||||
## 4. P0 blockers for the free launch
|
||||
|
||||
### L-01 — Replace premature paid messaging
|
||||
|
||||
**Owner:** developer + Ahmed. **Evidence:** /subscription, homepage membership section and VIP CTAs currently promise purchasing through Stripe.
|
||||
|
||||
**Change:** Replace purchase CTAs across home, membership, cards, article gates, menus and account pages. Retain /subscription URL but present early-access status and an optional, functional notification signup. Disable checkout initiation server-side as well as hiding buttons. Never publish a fake working purchase flow.
|
||||
|
||||
Suggested Arabic copy:
|
||||
|
||||
> وصول مبكر مجاني
|
||||
>
|
||||
> يمكنك قراءة المحتوى المنشور خلال فترة الوصول المبكر دون بطاقة دفع. نراجع إطلاق العضوية بعد نحو أسبوعين، وقد تمتد الفترة إذا لم تكتمل خدمة الدفع. بعض المواد محددة للعضوية مستقبلاً، وسنوضح ذلك قبل تفعيلها. لن يتم إنشاء اشتراك أو تحصيل مبلغ تلقائياً.
|
||||
|
||||
For an intended-VIP post:
|
||||
|
||||
> متاح مجاناً خلال الوصول المبكر. هذا المحتوى مخصص للعضوية لاحقاً؛ سنعلن موعد التفعيل قبل تغيير الوصول.
|
||||
|
||||
Use only when this is the actual implemented access policy. Do not put an unapproved fixed end date in the UI. Remove '$10 subscribe now' and completed-payment claims until payment is genuinely enabled. A future-price preview, if retained, must be labeled not currently purchasable and subject to owner confirmation.
|
||||
|
||||
**Acceptance:** Search all rendered templates and emails for obsolete purchase claims; direct checkout calls fail safely with an explicit unavailable response; no card data is requested; /subscription remains a useful page rather than 404.
|
||||
|
||||
### L-02 — Implement consistent launch-free access
|
||||
|
||||
**Owner:** backend developer. **Evidence:** six posts currently show public membership previews; desired open period is new policy.
|
||||
|
||||
**Change:** One server-side policy function controls article bodies and downloads. It checks publication/review status, actual launch mode, intended tier, and valid entitlement when paid mode is eventually enabled. Frontend labels consume the same effective-access decision.
|
||||
|
||||
**Acceptance:** Guest can read every approved launch-open post from its canonical URL, library and related links. Approved downloads work. Draft/private/unreviewed posts remain inaccessible. Original intended tier survives opening and rollback. Repeated config changes are idempotent. Date expiry alone cannot activate a paywall.
|
||||
|
||||
### L-03 — Finalize free-launch terms and privacy
|
||||
|
||||
**Owner:** Ahmed + qualified reviewer + developer. **Evidence:** /terms and /privacy are publicly marked drafts with unfinished instructions.
|
||||
|
||||
**Change:** Document the actual free service, content license, whether future VIP materials are temporary-access, contact channel, real data collection, providers, retention, deletion process and effective date. Distinguish operational email from optional marketing. Remove paid billing provisions that do not apply now or label future features accurately. Do not invent legal entity, governing law, retention duration or compliance claims.
|
||||
|
||||
**Acceptance:** Final pages contain facts approved by the operator, no editorial TODO instructions, and a real effective date. Their account and analytics descriptions match the deployed behavior. If analytics requires consent in the applicable context, blocking and withdrawal work. Legal text is professionally reviewed as appropriate; this document is not legal advice.
|
||||
|
||||
### L-04 — Test the complete reading and download journey
|
||||
|
||||
**Owner:** QA/developer. **Status:** unverified, not a known failure.
|
||||
|
||||
**Change:** Test home → library → each launch article → approved file download on desktop and mobile, including Safari and Telegram in-app browser. Check filters, navigation, pagination if any, table/code readability, Arabic text mixed with English commands, and contact links.
|
||||
|
||||
**Acceptance:** No placeholder/empty files; names and contents match promises; links and images resolve; keyboard controls and labels work; no horizontal page overflow. Code blocks may scroll independently. For meaningful diagrams use descriptive alt; decorative card covers may have empty alt. Store a browser/version test log and example screenshots.
|
||||
|
||||
### L-05 — Protect private notes and publishing permissions
|
||||
|
||||
**Owner:** developer + editorial operator. **Status:** new requirement for the publishing pipeline, not an observed leak.
|
||||
|
||||
**Change:** Separate public artifacts from AppFlowy/Obsidian sources. Restrict the agent to explicitly approved notes and CMS permissions. Scan text, images and downloads for credentials, private URLs, customer data, internal names and unlicensed assets. Sanitize Markdown/HTML, block executable content and unsafe URLs.
|
||||
|
||||
**Acceptance:** Deliberately seeded private markers in test inputs never reach public output. Source notes remain unchanged. An agent cannot publish another author's private note, alter a tier, modify billing, or inject unsafe HTML. Logs contain identifiers and outcomes rather than secrets/full private source text.
|
||||
|
||||
### L-06 — Reliable feedback, monitoring and rollback
|
||||
|
||||
**Owner:** developer + operator. **Status:** unverified.
|
||||
|
||||
**Change:** Ensure at least one monitored support/feedback route works. Add uptime/error visibility, persistence/backups and a tested restore. Test optional signup/reset flows before promoting them; disable unfinished features rather than presenting stubs.
|
||||
|
||||
**Acceptance:** A test feedback request reaches the actual destination and confirmation is truthful. A restore/rollback rehearsal recovers public content and configuration. Transactional notices reach the intended test mailbox without leaking data. Operator and emergency contact are named in the runbook.
|
||||
|
||||
## 5. P1 work during the early-access period
|
||||
|
||||
### L-07 — Content labels and reading estimates
|
||||
|
||||
**Owner:** editorial + developer. Add visible 'ملاحظة تقنية', 'دليل عملي', 'تجربة موثقة' or equivalent content-type labels. Compute reading estimate from article body using a configurable rate validated with Arabic readers; exclude navigation/comments and treat exercise time separately. Do not force a minimum long reading time.
|
||||
|
||||
**Acceptance:** Short notes are labeled honestly; tutorials show prerequisites and usable steps. The first-agent and RAG labels/estimates match actual content. No arbitrary padding to increase word count.
|
||||
|
||||
### L-08 — Metadata and domain hygiene
|
||||
|
||||
**Owner:** developer + editorial. Write individual descriptions for home/library/early-access/about. Improve discoverability-oriented titles without adding unsupported topics. Review noindex for login/signup, remove utility pages from sitemap, retain crawl visibility so noindex can be read. Permanently redirect www to apex while preserving path/query. Test nonexisting paths remain 404 and HTTPS canonical consistency.
|
||||
|
||||
**Acceptance:** Public posts remain indexable, private/draft pages do not enter sitemap, sitemap URLs return canonical 200s, metadata matches actual article and access status. Never ship staging noindex to public content.
|
||||
|
||||
### L-09 — Author, evidence and structured data
|
||||
|
||||
**Owner:** editorial + developer. Display real author/reviewer and sources. Enhance Article/BlogPosting fields with author profile, image, dates and canonical identity where correct. Do not invent credentials, reviews or performance data. Validate initial and rendered markup with suitable validators. Preserve existing useful schema.
|
||||
|
||||
**Acceptance:** Visible author and schema agree. Update date reflects real substantive updates. Early-access availability and future paywall fields match actual current access; no false paid Offer while billing is off. Validators report understood results; no promise of a special Google AI ranking boost.
|
||||
|
||||
### L-10 — Publishing workflow and deduplication
|
||||
|
||||
**Owner:** developer + editorial. Implement the companion agent prompt. Map proposed metadata to real CMS fields, create drafts first, review evidence and redactions, approve a specific revision, then publish and read it back. Use stable source IDs plus revision/hash to prevent duplicate posts; existing slugs should not change silently.
|
||||
|
||||
**Acceptance:** Same source retried produces no duplicate. Missing-source and failed-test inputs stop safely. Live output is verified against the approved draft. Retries after an ambiguous publish first search/read the expected record. Previous revisions can be restored. No note is marked published until verification succeeds.
|
||||
|
||||
### L-11 — Measure the launch
|
||||
|
||||
**Owner:** operator + developer. Configure privacy-respecting analytics and search ownership. Suggested events: article_view, meaningful_read, download_success, signup_confirmed, notification_opt_in_confirmed, feedback_received. Fire confirmations on real success, not button clicks. Define meaningful_read rather than using a page-open event as consumption.
|
||||
|
||||
**Acceptance:** Test event evidence exists, duplicate analytics installation is avoided, consent is respected, no source notes/emails/credentials appear in event parameters. Verify sitemap and priority URL inspection in Search Console; a search-tool miss is not proof of nonindexation.
|
||||
|
||||
### L-12 — Content and launch distribution
|
||||
|
||||
**Owner:** Ahmed/editorial. Select an initial set of useful notes and at least one demonstrated implementation if advertised as an implementation library. Publish a specific n8n guide only when an approved working example exists. Keep related links useful; differentiate overlapping beginner posts.
|
||||
|
||||
Use the launch skill's owned/rented/borrowed channel mix proportionally:
|
||||
- Owned: website and opt-in email updates.
|
||||
- Rented: one LinkedIn announcement and a real demonstration where the audience is present.
|
||||
- Borrowed: ask relevant collaborators for feedback/share only with permission.
|
||||
|
||||
**Acceptance:** Announcement links to an actually accessible post, accurately states free early access, makes no fabricated testimonial or availability claim, and has a monitored feedback path. Product Hunt, affiliate programs and paid ads are optional, not prerequisites for this content launch.
|
||||
|
||||
## 6. Stripe/VIP gate — required only before paid launch
|
||||
|
||||
Do not block the initial free reading experience on these. Do block purchases and paid enforcement until complete.
|
||||
|
||||
### S-01 — Business and account readiness
|
||||
|
||||
Operator checks actual Stripe entity eligibility, verification, settlement/payout setup and enabled capabilities for the business jurisdiction. Do not assume an account is eligible because customers are Saudi or a domain is .sa. No workaround through an unsupported entity. Resolve applicable billing/tax requirements with qualified advice. Do not promise a completion timeline based on this document.
|
||||
|
||||
### S-02 — Product, price and checkout
|
||||
|
||||
Create/verify the approved subscription product and immutable price reference in the correct environment. Confirm currency, monthly interval, actual amount, tax treatment and displayed totals. Use a hosted Checkout subscription flow rather than handling raw card details. Server selects the allowed price, amount and user; reject client-supplied substitutions. Map customer/subscription to the authenticated app user using trusted server-side data.
|
||||
|
||||
**Tests:** correct plan, tampered price, wrong customer, repeated checkout, cancelled checkout, authentication-required payment and declined payment. Returning to a success URL never grants VIP by itself. Early free access is not retroactively billed.
|
||||
|
||||
### S-03 — Verified webhook processing
|
||||
|
||||
Use official signature verification with the raw body and the proper environment-specific signing secret. Persist event IDs, process duplicates safely and reconcile subscription state instead of assuming delivery order. Durably record/enqueue work before acknowledging; maintain retry/dead-letter visibility. Use narrowly selected relevant events such as checkout completion, invoice paid/payment failure and subscription changes/end as required by the chosen integration; account for delayed payment methods if enabled.
|
||||
|
||||
**Tests:** invalid signature, duplicate delivery, out-of-order event, network interruption, delayed event, update/cancellation and recovery. A duplicate event neither duplicates entitlements nor creates additional charges. Test mode never activates production VIP rights.
|
||||
|
||||
### S-04 — Server-side entitlements and lifecycle policy
|
||||
|
||||
Define explicit access rules for active, incomplete, unpaid, past-due, canceled and any genuinely supported trial states. Document grace periods; do not invent one silently. Maintain paid-through timestamps and cancellation-at-period-end behavior consistent with promises. Reconcile against Stripe after missed events; do not revoke valid existing access merely because of a transient retrieval error. New unverifiable purchases stay pending and are not granted based on UI redirects.
|
||||
|
||||
**Tests:** paid user receives correct access; nonpayer cannot gain VIP by changing frontend storage; scheduled cancellation retains access through paid end and expires correctly; renewal extends once; failed renewal follows the approved policy; reversal/refund/dispute handling matches business policy.
|
||||
|
||||
### S-05 — Prevent full-content leaks
|
||||
|
||||
Paid gating must exclude restricted bodies/assets for unauthorized users from server HTML, page hydration/RSC payloads, APIs, GraphQL/REST responses, RSS/search feeds, public files, alternate Markdown and optional llms exports. Do not send full bodies and blur/hide them with CSS. Protect VIP downloads server-side and use scoped delivery where needed.
|
||||
|
||||
Separate guest/free/member caching; invalidate public copies of newly restricted content when switching. Previously public copies on third-party caches cannot be fully recalled.
|
||||
|
||||
**Tests:** guest/free user sees a useful public intro but never the restricted payload; valid member can read/download; expired member is denied; direct asset/API attempts do not bypass checks. If a crawler sampling policy is used, follow applicable search guidance without returning special promotional content or disabling authentication for arbitrary bot strings.
|
||||
|
||||
### S-06 — Customer self-service and notices
|
||||
|
||||
Provide a tested billing-management path for invoices, payment-method updates and cancellation. The app must generate a portal session for the logged-in user's own customer, not arbitrary IDs. Verify actual supported portal language; do not promise Arabic availability without testing. Keep Arabic app guidance around an English portal if needed. Confirm receipt/support contacts, statement details, renewal/cancellation notices and refund policy.
|
||||
|
||||
### S-07 — Sandbox acceptance and production preparation
|
||||
|
||||
Use Stripe sandbox/test resources and official test methods for success, failures, authentication, renewals and time-based lifecycle scenarios. Do not use real card data or arbitrary real purchases for testing. Record environment, SDK/API versions, relevant object/event IDs, expected result, actual result and pass/fail, with secrets removed. Test signing-secret rotation and failure visibility. Production keys and price IDs are stored securely and never inserted into source notes, prompts or repositories.
|
||||
|
||||
### S-08 — Owner-approved transition and rollback
|
||||
|
||||
Before switch, take an access/content snapshot, confirm notification copy and approve a list of specific intended-VIP posts/assets. Keep canonical URLs; retain public introductions and meaningful metadata. Synchronize all counters, cards, comparison tables and policies. Invalidate relevant caches and verify guest/member journeys immediately.
|
||||
|
||||
Rollback should disable new purchases and revert the faulty restriction configuration without deleting Stripe records, canceling paid customers or erasing paid entitlements. Repair paid readers' access quickly and reconcile outstanding events. A kill switch is not authorization to give all private content away.
|
||||
|
||||
## 7. Approximate two-week operating plan
|
||||
|
||||
Relative timing only; actual launch date is not yet set.
|
||||
|
||||
- Before day 1: close L-01 through L-06, approve content/access labels, and complete the free-launch QA gate.
|
||||
- First few days: publish one useful launch post, invite a small set of readers, gather specific feedback, fix broken journeys before wider announcement.
|
||||
- First week: ship reviewed notes and at least one evidence-backed implementation if available; correct metadata/reading estimates; test notifications and source pipeline.
|
||||
- Second week: review read/download/feedback evidence, publish improvements, and complete Stripe sandbox work in parallel.
|
||||
- Around the two-week checkpoint: inspect the free experience and Stripe/VIP test matrix. Either extend early access honestly or schedule an owner-approved paid transition. Do not activate gating solely because a calendar timer expired.
|
||||
|
||||
No automatic schedule or campaign was created by this document.
|
||||
|
||||
## 8. Developer signoff checklist
|
||||
|
||||
### Free launch GO only when
|
||||
|
||||
- [ ] L-01: paid claims and backend purchase paths are disabled consistently.
|
||||
- [ ] L-02: all approved launch-open content/assets work for intended readers; private content stays private.
|
||||
- [ ] L-03: real free-launch terms/privacy/access expectations are approved and published.
|
||||
- [ ] L-04: desktop/mobile/in-app reading and download QA passes.
|
||||
- [ ] L-05: note redaction, safe rendering and agent permissions pass negative tests.
|
||||
- [ ] L-06: feedback, monitoring, backup/restore and rollback work.
|
||||
- [ ] Ahmed approves the precise launch-open set and announcement.
|
||||
|
||||
### Paid launch GO only when
|
||||
|
||||
- [ ] S-01 to S-08 pass with recorded evidence.
|
||||
- [ ] Actual billing currency, amount, tax and refund handling are approved.
|
||||
- [ ] Guest, free, active member and expired member access matrices pass.
|
||||
- [ ] No restricted data appears in anonymous responses/feeds/assets/caches.
|
||||
- [ ] Readers receive honest notice of the transition and no automatic charge.
|
||||
- [ ] Ahmed signs off the transition; operator and rollback owner are assigned.
|
||||
|
||||
For each item record: ticket ID; assignee; environment; PR/deployment ID; test steps; expected/actual result; evidence link; status; remaining risk; reviewer. A checkbox without evidence is not release verification.
|
||||
|
||||
## 9. Decisions Ahmed/developer must settle
|
||||
|
||||
1. Which existing intended-VIP posts/assets are temporarily opened? Recommended: approved published VIP materials only, not raw notes or drafts.
|
||||
2. Which launch tutorials remain permanently free? Recommended: retain core introductory tutorials for discovery.
|
||||
3. What must require an account? Recommended: guest reading; account only for tested save/comment functions.
|
||||
4. Is an email-update feature ready? If not, omit the CTA rather than deploying a fake signup.
|
||||
5. What notice period will be used before restricting temporary VIP early-access materials? Agree and implement; no arbitrary period is treated as approved here.
|
||||
6. Who approves posts and launch switches? Until explicitly delegated, human approval is required for a specific article revision and for paid-mode changes.
|
||||
7. What are the actual retention periods, providers, legal entity and billing jurisdiction? Operator supplies facts; the agent must not invent them.
|
||||
|
||||
## 10. Reference links
|
||||
|
||||
Website evidence:
|
||||
- https://anwarpy.ai/
|
||||
- https://anwarpy.ai/articles
|
||||
- https://anwarpy.ai/subscription
|
||||
- https://anwarpy.ai/terms
|
||||
- https://anwarpy.ai/privacy
|
||||
- https://anwarpy.ai/posts/first-arabic-agent
|
||||
- https://anwarpy.ai/posts/simple-arabic-rag
|
||||
- https://anwarpy.ai/posts/dograh-voice-agent-evaluation
|
||||
- https://anwarpy.ai/sitemap.xml
|
||||
- https://anwarpy.ai/robots.txt
|
||||
|
||||
Stripe primary guidance consulted:
|
||||
- https://docs.stripe.com/billing/subscriptions/webhooks
|
||||
- https://docs.stripe.com/webhooks
|
||||
- https://docs.stripe.com/testing
|
||||
- https://docs.stripe.com/customer-management
|
||||
|
||||
Companion document: anwarpy-blog-publishing-agent-prompt.md. The prompt controls content generation and approval, not billing activation.
|
||||
|
||||
## ملخص عربي
|
||||
|
||||
الإطلاق المدفوع غير جاهز الآن لأن ربط Stripe لم يكتمل. يمكن إطلاق قراءة مجانية مبكرة بعد إصلاح رسائل الاشتراك والسياسات، وفتح المواد المعتمدة فقط، واختبار القراءة والتنزيل وحماية الملاحظات الخاصة. الأسبوعان نقطة مراجعة وليسا موعد حجب تلقائي. الملاحظات القصيرة مقبولة إذا سُمّيت ملاحظات وكان وقت القراءة صادقاً. لا تتحول المواد إلى VIP إلا بعد نجاح اختبارات الدفع والصلاحيات وإبلاغ القراء وموافقة أحمد، ولا يُنشأ اشتراك أو يُحصّل مبلغ تلقائياً.
|
||||
|
||||
---
|
||||
|
||||
## 11. Evidence — free-launch items executed 2026-10-10
|
||||
|
||||
Launch implemented on branch `launch/early-access-2026-10-10` (step references = execution plan). `LAUNCH_MODE=free_early_access` is the compose default; billing stays off until owner approval. Per the checklist rule, a checkbox without evidence is not verification — prod-verify items below are re-checked at step 48 and the lines updated.
|
||||
|
||||
- **L-01 (paid claims off)** — Steps 8–11, 21, 22: `/subscription` shows an early-access panel + working notify form; all subscribe CTAs render a disabled «قريباً» button when billing is off (`subscribe-button.tsx` comingSoon prop); `POST /api/subscribe` returns 503 server-side with the exact Arabic error; home membership copy swept for purchase claims. **Prod-verified 2026-10-10: `POST https://anwarpy.ai/api/subscribe` → `503 {"error":"الدفع غير مفعّل خلال فترة الوصول المبكر."}`.**
|
||||
- **L-02 (launch-free access policy)** — Steps 1–6: `src/lib/launch.ts` policy (`LAUNCH_MODE`, fail-safe default = free early access, no date expiry), field-level `content` read ACL in `Posts.ts` (public iff `status=published && (access=free || early)`) — same decision consumed by pages, `/api/posts/[slug]`, file gate, and llms-full. Intended tier (`access`) never rewritten. **Prod-verified: ghost-read of intended-VIP `/posts/agent-loop-stop-condition` serves the full body + early-access notice, zero paywall markers, `isAccessibleForFree: true`.**
|
||||
- **L-03 (terms/privacy)** — **NOT DONE — owner facts pending.** `docs/launch/owner-inputs-terms-privacy.md` lists exactly what Ahmed must supply; `/terms` and `/privacy` untouched until then.
|
||||
- **L-04 (reading/download QA)** — Step 45 local smoke + step 48 prod QA at HTTP level (home → library → topic hub → article; reading time «دقيقتان», no horizontal-marker regressions observed in markup). Safari and Telegram in-app browsers are owner-manual tests. A full visual browser pass remains open.
|
||||
- **L-05 (private notes / pipeline permissions)** — Steps 28–30: agent API strips `access`/`readingMinutes` from accepted fields; `sourceId` dedup returns the existing post (no duplicate); `scanForSecrets` 422s payloads matching AWS AKIA / PEM / sk- / ghp_ / generic-credential patterns (pattern names only in the response); publish stays an explicit opt-in and PUT cannot set `status`. **Post-copy fixes found in smoke, included in the merged commit:** `lexicalToText`/`countLexicalWords` never unwrapped the Lexical `root` (scan was inert on real content — fixed + verified 422), string `content` was silently replaced with a placeholder (now 400), and llms-full never requested `content` (`fetchIndexDocs(true)` — prod llms-full now 49 KB with bodies).
|
||||
- **L-06 (feedback, backup, rollback)** — Step 40: `scripts/backup-db.sh` + restore rehearsal on the VPS (2026-10-10): `pg_dump` → gzip 25K, restored into `anwarpi_restore_test` with 0 SQL errors, counts `posts=15, users=6, payload_migrations=6`, temp DB dropped. Feedback: footer `mailto:hello@anwarpy.ai` (opens the visitor's mail client; no server dependency). Production mail transport test via the same Cloudflare Email Sending REST call the app uses → `10203 email.sending_disabled` — **the account feature is off; transactional mail (verification, notify confirmations) will honestly report failure until the owner enables Email Sending** (prod `/api/notify` honestly 503s and still records the row). Monitoring/uptime tooling: owner decision, recorded not built.
|
||||
- **L-07 (labels + reading estimates)** — Steps 24–26: `contentType` badge, `authorName` byline, «حُدّث» freshness line; `readingMinutes` computed at write time via `countLexicalWords`/`READING_WORDS_PER_MINUTE` (160 wpm). **Prod backfill run 2026-10-10 (local tsx over an SSH tunnel to the prod DB — the standalone container has no `scripts/`): 15 scanned, 15 updated** (old estimates were inflated: 18→2, 12→2, 10→1, …).
|
||||
- **L-08 (metadata/domain hygiene)** — Steps 32, 37, 38, 39: Arabic descriptions on articles/subscription/about; sitemap gains `/topics/*` and drops `/login`,`/signup`; those pages (plus forgot-password) are `robots: noindex, follow`; Traefik `anwarpi-www` router permanently redirects `https://www.anwarpy.ai/*` → apex preserving path (prod-verified: `HTTP/2 308`, `location: https://anwarpy.ai/posts`). Sitemap prod-verified: 4 `/topics/*`, no `/login`,`/signup`. og:image 1200×630 + og:site_name present on articles.
|
||||
- **L-09 (author/evidence/structured data)** — Step 33: Article JSON-LD with `image`, `dateModified`, `description`, `mainEntityOfPage`, publisher logo, `author` = Person (authorName) or Organization; `isAccessibleForFree` = actual current access (never a false paid Offer while billing is off); BreadcrumbList (home → topic hub → post); FAQPage on `/subscription`; Organization on home. Visible byline and schema share the same fields.
|
||||
- **L-10 (publishing workflow/dedup)** — Steps 24, 28–31: agent creates drafts (`status` forced draft unless `publish: true`), `sourceId` unique-ish dedup before slug-409, secret scan on POST/PUT.
|
||||
- **L-11 (measurement)** — **NOT BUILT — owner decision** (analytics vendor; recorded only). Suggested events remain in the checklist.
|
||||
- **L-12 (content/distribution)** — **Owner/editorial.** No announcement drafted or sent by the agent.
|
||||
|
||||
### Prod verification 2026-10-10 (post-deploy)
|
||||
|
||||
Deployed to `root@147.79.102.201:/root/anwarpi` via rsync + `docker compose -f docker-compose.vps.yml up -d --build`; boot applied migration `20261010_142214`; `LAUNCH_MODE=free_early_access` set in `.env.production`. Verified live: home/admin 200; ghost VIP read full-body; subscribe 503 honest; notify honest-503 + row persisted (row deleted after); signup 201 + `verification_token` written (row deleted after); robots lists the 10 named AI crawlers; `/llms.txt` + `/llms-full.txt` (49 KB, bodies); `/topics/agents` 200; sitemap 4 topics, no auth pages; www 308 → apex; Article + BreadcrumbList JSON-LD; reading minutes honest («دقيقتان»). PR #3 merged to `main` (638a590).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Owner inputs required for L-03 — /terms and /privacy
|
||||
|
||||
Ahmed must supply the facts below before `/terms` or `/privacy` are edited. The agent will not invent any of them (checklist §9.7). Once provided, the pages get real text, an effective date, and go into the launch checklist evidence.
|
||||
|
||||
## Legal facts (checklist L-03 + §9)
|
||||
|
||||
1. **Legal entity** — registered company name and form, or an explicit statement that the site is operated as a sole proprietorship (and under what name).
|
||||
2. **Governing law / jurisdiction** — the law that governs the terms and the courts or venue for disputes.
|
||||
3. **Retention periods per data type** — accounts and hashed passwords, verification/reset tokens, comments, saved posts, launch-notification emails, server logs. Include "until account deletion" where that is the real answer.
|
||||
4. **Full provider list** — as currently known: Cloudflare (DNS/proxy, Email Routing + Email Sending), VPS host for 147.79.102.201, Postgres (self-hosted on that VPS). Confirm or correct; note anything that processes personal data outside these.
|
||||
5. **Data-deletion process** — how a user requests deletion (email address or in-app), what is deleted vs retained, and the timeframe.
|
||||
6. **Contact channel** — the address that appears in the pages and must actually reach Ahmed (currently the footer `mailto:hello@anwarpy.ai`).
|
||||
7. **Effective date** — the date the final terms/privacy go live (this becomes the published "آخر تحديث").
|
||||
|
||||
## Checklist §9 decisions (needed to finalize the same pages)
|
||||
|
||||
1. Which existing intended-VIP posts/assets are temporarily opened? (Recommended: approved published VIP materials only.)
|
||||
2. Which launch tutorials remain permanently free? (Recommended: core introductory tutorials.)
|
||||
3. What requires an account? (Recommended: guest reading; account only for save/comment.)
|
||||
4. Is an email-update feature ready? (The notify list exists; confirm it may be advertised.)
|
||||
5. Notice period before restricting temporary VIP early-access materials.
|
||||
6. Who approves posts and launch switches (Ahmed until delegated)?
|
||||
7. Actual retention periods, providers, legal entity and billing jurisdiction (see items 1–7 above).
|
||||
|
||||
## Operational blocker found 2026-10-10 (also affects the pages' email claims)
|
||||
|
||||
Cloudflare Email Sending is **disabled** on the account (API test returned `10203 email.sending_disabled`). Before any page or flow promises email delivery: enable Email Sending in the Cloudflare dashboard (Account → Email), then re-test the transport. The footer mailto link is unaffected (it opens the visitor's own mail client).
|
||||
@@ -30,17 +30,29 @@ services:
|
||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET}
|
||||
AGENT_API_KEY: ${AGENT_API_KEY}
|
||||
NEXT_PUBLIC_SITE_URL: https://anwarpy.ai
|
||||
LAUNCH_MODE: ${LAUNCH_MODE:-free_early_access}
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID}
|
||||
MAIL_FROM: noreply@anwarpy.ai
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`)
|
||||
- traefik.http.routers.anwarpi.rule=Host(`anwarpy.ai`)
|
||||
- traefik.http.routers.anwarpi.entrypoints=websecure
|
||||
- traefik.http.routers.anwarpi.tls.certresolver=mytlschallenge
|
||||
- traefik.http.routers.anwarpi.middlewares=pagelimit@docker,connlimit@docker
|
||||
- traefik.http.services.anwarpi.loadbalancer.server.port=3000
|
||||
- traefik.http.routers.anwarpi-api.rule=PathPrefix(`/api`) && (Host(`anwarpy.ai`) || Host(`www.anwarpy.ai`))
|
||||
# 39: canonical host is the apex. www hits this router first (priority
|
||||
# above anwarpi-api's 100) and is 301-redirected before any rate limit.
|
||||
- traefik.http.routers.anwarpi-www.rule=Host(`www.anwarpy.ai`)
|
||||
- traefik.http.routers.anwarpi-www.entrypoints=websecure
|
||||
- traefik.http.routers.anwarpi-www.tls.certresolver=mytlschallenge
|
||||
- traefik.http.routers.anwarpi-www.priority=110
|
||||
- traefik.http.routers.anwarpi-www.service=anwarpi
|
||||
- traefik.http.routers.anwarpi-www.middlewares=www-redirect
|
||||
- traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\.anwarpy\.ai/(.*)
|
||||
- traefik.http.middlewares.www-redirect.redirectregex.replacement=https://anwarpy.ai/$$1
|
||||
- traefik.http.middlewares.www-redirect.redirectregex.permanent=true
|
||||
- traefik.http.routers.anwarpi-api.rule=PathPrefix(`/api`) && Host(`anwarpy.ai`)
|
||||
- traefik.http.routers.anwarpi-api.entrypoints=websecure
|
||||
- traefik.http.routers.anwarpi-api.tls=true
|
||||
- traefik.http.routers.anwarpi-api.tls.certresolver=mytlschallenge
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Backfill `readingMinutes` on every post from its body (L-07).
|
||||
* No schema change — safe to re-run any time.
|
||||
*
|
||||
* Usage: cd web && npx tsx scripts/backfill-reading-minutes.ts
|
||||
*/
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { countLexicalWords } from '../src/lib/lexical'
|
||||
import { READING_WORDS_PER_MINUTE } from '../src/lib/launch'
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
let updated = 0
|
||||
for (const post of docs) {
|
||||
const words = countLexicalWords(post.content)
|
||||
const readingMinutes = Math.max(1, Math.round(words / READING_WORDS_PER_MINUTE))
|
||||
if (readingMinutes !== post.readingMinutes) {
|
||||
await payload.update({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
data: { readingMinutes },
|
||||
overrideAccess: true,
|
||||
context: { systemBackfill: true },
|
||||
})
|
||||
payload.logger.info(`backfill: ${post.slug} ${post.readingMinutes} → ${readingMinutes} (${words} words)`)
|
||||
updated += 1
|
||||
}
|
||||
}
|
||||
|
||||
payload.logger.info(`backfill complete: ${docs.length} scanned, ${updated} updated`)
|
||||
// Postgres pool keeps the loop alive — release it so the script exits.
|
||||
await payload.db.destroy?.()
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# anwarpy.ai — Postgres backup (L-06)
|
||||
#
|
||||
# Run ON THE VPS (needs the `proxy`/compose network up):
|
||||
# ./scripts/backup-db.sh
|
||||
#
|
||||
# Cron (nightly 03:15, keep dir writable):
|
||||
# 15 3 * * * cd /root/anwarpi && ./scripts/backup-db.sh >> backups/backup.log 2>&1
|
||||
#
|
||||
# Restore a dump into the running db container:
|
||||
# gunzip -c backups/anwarpi-YYYYMMDD-HHMMSS.sql.gz | \
|
||||
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi anwarpi
|
||||
#
|
||||
# Restore-rehearsal (verify a backup is loadable BEFORE you need it):
|
||||
# gunzip -c backups/<file>.sql.gz | \
|
||||
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -c "CREATE DATABASE anwarpi_restore_test;"
|
||||
# gunzip -c backups/<file>.sql.gz | \
|
||||
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -d anwarpi_restore_test -q
|
||||
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -d anwarpi_restore_test -tAc \
|
||||
# "SELECT (SELECT count(*) FROM posts) AS posts, (SELECT count(*) FROM users) AS users;"
|
||||
# docker compose -f docker-compose.vps.yml exec -T db psql -U anwarpi -c "DROP DATABASE anwarpi_restore_test;"
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
mkdir -p backups
|
||||
STAMP=$(date +%Y%m%d-%H%M%S)
|
||||
OUT="backups/anwarpi-${STAMP}.sql.gz"
|
||||
|
||||
docker compose -f docker-compose.vps.yml exec -T db pg_dump -U anwarpi anwarpi | gzip > "$OUT"
|
||||
echo "wrote $OUT ($(du -h "$OUT" | cut -f1))"
|
||||
@@ -3,7 +3,11 @@ import Footer from '@/components/footer'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'عن الفريق' }
|
||||
export const metadata = {
|
||||
title: 'عن الفريق',
|
||||
description:
|
||||
'فريق أنور بآي: عرب يكتبون أدلة ذكاء اصطناعي عملية — تجارب موثقة، خطوات قابلة للتطبيق، وملفات عمل حقيقية بلا حشو تسويقي.',
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
const viewer = await getViewer()
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props = {
|
||||
userId: number
|
||||
fullName: string
|
||||
role: string
|
||||
billingEnabled: boolean
|
||||
}
|
||||
|
||||
type Status = { ok: boolean; text: string } | null
|
||||
@@ -25,7 +26,7 @@ const TABS = [
|
||||
{ id: 'membership', label: 'العضوية والفوترة' },
|
||||
]
|
||||
|
||||
export default function AccountSettingsClient({ email, userId, fullName, role }: Props) {
|
||||
export default function AccountSettingsClient({ email, userId, fullName, role, billingEnabled }: Props) {
|
||||
const router = useRouter()
|
||||
const [tab, setTab] = useState('profile')
|
||||
const [name, setName] = useState(fullName)
|
||||
@@ -172,13 +173,15 @@ export default function AccountSettingsClient({ email, userId, fullName, role }:
|
||||
<span className={`state-chip${entitled ? '' : ' cancelled'}`} id="planState">{entitled ? 'نشطة' : role === 'admin' ? 'إداري' : 'مجاني'}</span>
|
||||
</div>
|
||||
<div className="plan-details">
|
||||
<div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></span></div>
|
||||
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : 'الأدلة المجانية ومعاينة الأدلة'}</dd></dl>
|
||||
<dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">ستُربط عبر Stripe</dd></dl>
|
||||
{billingEnabled
|
||||
? <div><span className="price-tag"><bdi dir="ltr">$10</bdi><small>شهرياً</small></span></div>
|
||||
: <div><span className="price-tag price-tag-early">وصول مبكر مجاني</span></div>}
|
||||
<dl style={{ margin: 0 }}><dt>الوصول</dt><dd>{entitled ? 'كامل — كل الأدلة الحصرية وملفات العمل' : billingEnabled ? 'الأدلة المجانية ومعاينة الأدلة' : 'كل المحتوى المنشور مؤقتاً — الوصول المبكر المجاني'}</dd></dl>
|
||||
<dl style={{ margin: 0 }}><dt>طريقة الدفع</dt><dd id="payMethod">{billingEnabled ? 'ستُربط عبر Stripe' : 'لا يوجد — لن يُحصَّل أي مبلغ خلال الوصول المبكر'}</dd></dl>
|
||||
<dl style={{ margin: 0 }}><dt>الوصول بعد الإلغاء</dt><dd>حتى نهاية الفترة المدفوعة</dd></dl>
|
||||
</div>
|
||||
<div className="membership-actions" id="membershipActions">
|
||||
{!entitled && <Link className="btn btn-primary" href="/subscription" id="resubLink">اشترك الآن</Link>}
|
||||
{!entitled && billingEnabled && <Link className="btn btn-primary" href="/subscription" id="resubLink">اشترك الآن</Link>}
|
||||
</div>
|
||||
</article>
|
||||
<div className="cancel-zone" id="cancelZone" data-od-id="cancel-zone">
|
||||
|
||||
@@ -4,6 +4,7 @@ import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
import AccountSettingsClient from './client'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { billingEnabled } from '@/lib/launch'
|
||||
import configPromise from '@payload-config'
|
||||
import '../styles/account-settings.css'
|
||||
|
||||
@@ -30,6 +31,7 @@ export default async function AccountSettingsPage() {
|
||||
userId={Number(user.id)}
|
||||
fullName={(user.fullName as string) || ''}
|
||||
role={((user.role as string) || 'free')}
|
||||
billingEnabled={billingEnabled()}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -6,10 +6,14 @@ import configPromise from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import ArticlesClient from './articles-client'
|
||||
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
|
||||
import { formatNumber, guidesLabel, minutesLabel } from '@/lib/format'
|
||||
import { formatNumber, guidesLabel, minutesLabel, contentTypeLabel } from '@/lib/format'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'المكتبة' }
|
||||
export const metadata = {
|
||||
title: 'المكتبة',
|
||||
description:
|
||||
'أدلة الذكاء الاصطناعي العملية بالعربية: وكلاء، نماذج، استرجاع، سلامة، أدوات، وأعمال — بخطوات قابلة للتطبيق وتجارب موثقة وملفات عمل.',
|
||||
}
|
||||
|
||||
type PostDoc = {
|
||||
id: number
|
||||
@@ -24,6 +28,7 @@ type PostDoc = {
|
||||
likes: number
|
||||
publishedAt: string
|
||||
cover?: { url?: string | null } | number | null
|
||||
contentType?: string | null
|
||||
}
|
||||
|
||||
type Viewer = { id: number; email: string; role: string; interest: string | null; level: string | null } | null
|
||||
@@ -40,6 +45,7 @@ export default async function ArticlesPage() {
|
||||
select: {
|
||||
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
||||
readingMinutes: true, views: true, likes: true, publishedAt: true, cover: true,
|
||||
contentType: true,
|
||||
},
|
||||
}),
|
||||
payload.auth({ headers: await headers() }),
|
||||
@@ -117,6 +123,9 @@ export default async function ArticlesPage() {
|
||||
|
||||
<section className="lib-results" aria-label="الأدلة">
|
||||
<div className="wrap">
|
||||
<nav className="topic-strip" aria-label="تصفّح المسارات">
|
||||
{TOPICS.map(topic => <Link key={topic.key} href={`/topics/${topic.key}`}>{topic.label}</Link>)}
|
||||
</nav>
|
||||
<p className="results-count" id="resultCount" aria-live="polite">{guidesLabel(posts.length)}</p>
|
||||
<div className="lib-grid" id="postList">
|
||||
{posts.map(post => {
|
||||
@@ -139,6 +148,7 @@ export default async function ArticlesPage() {
|
||||
<div className="guide-card-body">
|
||||
<p className="meta-row">
|
||||
<span>{post.category}</span>
|
||||
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
|
||||
<span>{minutesLabel(post.readingMinutes)}</span>
|
||||
<span>{post.level}</span>
|
||||
{meta._match && <span className="for-you">يناسب مستواك</span>}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export type DeckSlide = {
|
||||
kicker: string
|
||||
index: string
|
||||
title: React.ReactNode
|
||||
body: string
|
||||
format: React.ReactNode
|
||||
link?: { href: string; label: string }
|
||||
meta?: React.ReactNode
|
||||
}
|
||||
|
||||
const V1_SLIDES: DeckSlide[] = [
|
||||
{
|
||||
kicker: 'دليل حصري · المستوى المتوسط',
|
||||
index: '01',
|
||||
title: 'ابنِ وكيلك الأول دون فوضى',
|
||||
body: 'خريطة عملية لتقسيم المهام، اختيار الأدوات، وضبط ذاكرة الوكيل قبل كتابة الكود.',
|
||||
format: <><span className="play" aria-hidden="true">▶</span>فيديو · 28 دقيقة</>,
|
||||
link: { href: '/posts/agent-loop-stop-condition', label: 'شاهد المعاينة ←' },
|
||||
},
|
||||
{
|
||||
kicker: 'مقال تطبيقي · للمبتدئين',
|
||||
index: '02',
|
||||
title: 'كيف تختار نموذجاً لمهمتك؟',
|
||||
body: 'مقارنة منهجية حسب الجودة، التكلفة، السرعة، والخصوصية — من دون ضجيج تسويقي.',
|
||||
format: <>قراءة · 12 دقيقة</>,
|
||||
link: { href: '/posts/article-agent-guide', label: 'ابدأ القراءة ←' },
|
||||
},
|
||||
{
|
||||
kicker: 'مسار متقدم · للأعضاء',
|
||||
index: '03',
|
||||
title: <>من <bdi dir="ltr">RAG</bdi> إلى إجابة موثوقة</>,
|
||||
body: 'صمّم الاسترجاع، اختبر المصادر، وافهم أين تنهار الإجابة قبل أن تصل إلى المستخدم.',
|
||||
format: <><span className="play" aria-hidden="true">▶</span>سلسلة · 4 أجزاء</>,
|
||||
link: { href: '/subscription', label: 'شاهد للأعضاء ←' },
|
||||
},
|
||||
]
|
||||
|
||||
const DOT_LABELS = ['الشريحة الأولى', 'الشريحة الثانية', 'الشريحة الثالثة']
|
||||
|
||||
/* Featured content carousel. Manual selection pauses rotation until resumed. */
|
||||
export default function CatalogDeck({ slides = V1_SLIDES }: { slides?: DeckSlide[] }) {
|
||||
const [activeSlide, setActiveSlide] = useState(0)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [playing, setPlaying] = useState(true)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const current = slides.length ? activeSlide % slides.length : 0
|
||||
const advancing = ready && playing && !hovered && !focused && slides.length > 1
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = Number(localStorage.getItem('midad-slide'))
|
||||
if (Number.isInteger(stored) && stored >= 0) setActiveSlide(stored)
|
||||
} catch { /* Persistence is optional. */ }
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
if (motion.matches) setPlaying(false)
|
||||
const onMotion = () => { if (motion.matches) setPlaying(false) }
|
||||
motion.addEventListener('change', onMotion)
|
||||
setReady(true)
|
||||
return () => motion.removeEventListener('change', onMotion)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return
|
||||
try { localStorage.setItem('midad-slide', String(current)) } catch { /* optional persistence */ }
|
||||
}, [current, ready])
|
||||
|
||||
useEffect(() => {
|
||||
if (!advancing) return
|
||||
const timer = setTimeout(() => setActiveSlide(index => (index + 1) % slides.length), 6500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [advancing, current, slides.length])
|
||||
|
||||
function selectSlide(index: number) {
|
||||
setActiveSlide((index + slides.length) % slides.length)
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
if (!slides.length) return null
|
||||
|
||||
return (
|
||||
<div className="deck" data-od-id="featured-deck" role="region" aria-roledescription="عرض شرائح" aria-label="مختارات مميزة"
|
||||
onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
|
||||
onFocusCapture={() => setFocused(true)}
|
||||
onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'ArrowLeft') { event.preventDefault(); selectSlide(current + 1) }
|
||||
if (event.key === 'ArrowRight') { event.preventDefault(); selectSlide(current - 1) }
|
||||
}}>
|
||||
|
||||
<div className="deck-frame" aria-live={advancing ? 'off' : 'polite'}>
|
||||
{slides.map((s, i) => (
|
||||
<article className={`deck-slide${i === current ? ' active' : ''}`} hidden={i !== current} aria-label={`${i + 1} من ${slides.length}`} data-slide={i} key={i}>
|
||||
<div className="deck-slide-top"><span className="deck-slide-kicker">{s.kicker}</span><span className="deck-slide-index num">{s.index}</span></div>
|
||||
<div className="deck-slide-body"><h2>{s.title}</h2><p>{s.body}</p></div>
|
||||
<div className="deck-slide-foot">
|
||||
<span className="deck-slide-format">{s.format}</span>
|
||||
{s.link
|
||||
? <Link className="deck-slide-link" href={s.link.href}>{s.link.label}</Link>
|
||||
: <span className="meta">{s.meta}</span>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="deck-controls">
|
||||
<div className="deck-dots" aria-label="اختيار الشريحة">
|
||||
{slides.map((_, i) => (
|
||||
<button type="button" className={`deck-dot${i === current ? ' active' : ''}`} data-dot={i} aria-label={DOT_LABELS[i] ?? `الشريحة ${i + 1}`} aria-current={i === current ? 'true' : undefined} onClick={() => selectSlide(i)} key={i}></button>
|
||||
))}
|
||||
</div>
|
||||
<div className="deck-arrows">
|
||||
<button type="button" className="icon-btn" id="prevSlide" aria-label="الشريحة السابقة" disabled={slides.length < 2} onClick={() => selectSlide(current - 1)}>→</button>
|
||||
<button type="button" className="icon-btn" id="nextSlide" aria-label="الشريحة التالية" disabled={slides.length < 2} onClick={() => selectSlide(current + 1)}>←</button>
|
||||
{slides.length > 1 && <button type="button" className="icon-btn deck-play" aria-label={playing ? 'إيقاف العرض التلقائي' : 'تشغيل العرض التلقائي'} onClick={() => setPlaying(value => !value)}>{playing ? 'Ⅱ' : '▶'}</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/* Filter bar for the editors' picks. Cards carry space-separated topics. */
|
||||
export default function CatalogFilters() {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const filters = [...document.querySelectorAll('.filter')] as HTMLElement[]
|
||||
const cards = [...document.querySelectorAll('.catalog-card')] as HTMLElement[]
|
||||
const empty = document.getElementById('emptyState')
|
||||
|
||||
function filterCatalog(topic: string) {
|
||||
let visible = 0
|
||||
filters.forEach(button => {
|
||||
const selected = (button as HTMLElement).dataset.filter === topic
|
||||
button.classList.toggle('active', selected)
|
||||
button.setAttribute('aria-pressed', String(selected))
|
||||
})
|
||||
cards.forEach(card => {
|
||||
const show = topic === 'all' || (card.dataset.topic || '').split(' ').includes(topic)
|
||||
card.hidden = !show
|
||||
if (show) visible += 1
|
||||
})
|
||||
empty?.classList.toggle('show', visible === 0)
|
||||
}
|
||||
|
||||
filters.forEach(button => button.addEventListener('click', () => {
|
||||
button.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
filterCatalog((button as HTMLElement).dataset.filter!)
|
||||
}, { signal: controller.signal }))
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="filterbar" role="group" aria-label="تصفية المحتوى" data-od-id="catalog-filters">
|
||||
<button type="button" className="filter active" data-filter="all" aria-pressed="true">الكل</button>
|
||||
<button type="button" className="filter" data-filter="agents" aria-pressed="false">الوكلاء</button>
|
||||
<button type="button" className="filter" data-filter="models" aria-pressed="false">النماذج</button>
|
||||
<button type="button" className="filter" data-filter="automation" aria-pressed="false">الأتمتة</button>
|
||||
<button type="button" className="filter" data-filter="foundations" aria-pressed="false">الأساسيات</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
/* QA-07: /catalog was a stale duplicate library — /articles is canonical.
|
||||
Deck/filter components in this folder are still used by the homepage. */
|
||||
/* QA-07: /catalog was a stale duplicate library — /articles is canonical. */
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function CatalogPage() {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
/* QA-06: page-specific title (the page itself is a client component). */
|
||||
export const metadata: Metadata = { title: 'استعادة كلمة المرور' }
|
||||
/* QA-06: page-specific title (the page itself is a client component).
|
||||
Utility pages stay out of search indexes (step 38). */
|
||||
export const metadata: Metadata = {
|
||||
title: 'استعادة كلمة المرور',
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default function ForgotPasswordLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
/* QA-06: page-specific title (the page itself is a client component). */
|
||||
export const metadata: Metadata = { title: 'تسجيل الدخول' }
|
||||
/* QA-06: page-specific title (the page itself is a client component).
|
||||
Utility pages stay out of search indexes (step 38). */
|
||||
export const metadata: Metadata = {
|
||||
title: 'تسجيل الدخول',
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
|
||||
@@ -41,6 +41,23 @@ export default function LoginPage() {
|
||||
router.push(safeNext(new URLSearchParams(window.location.search).get('next')) ?? '/account-settings')
|
||||
return
|
||||
}
|
||||
const err = await res.json().catch(() => null) as { error?: string; errors?: { message?: string }[] } | null
|
||||
// Rate-limit / middleware shape first: { error: string } + optional Retry-After.
|
||||
if (typeof err?.error === 'string') {
|
||||
const retryAfterSec = Number(res.headers.get('Retry-After'))
|
||||
setStatus(
|
||||
res.headers.get('Retry-After') && Number.isFinite(retryAfterSec)
|
||||
? `${err.error} حاول بعد ${Math.max(1, Math.ceil(retryAfterSec / 60))} دقيقة.`
|
||||
: err.error,
|
||||
)
|
||||
return
|
||||
}
|
||||
const message = err?.errors?.[0]?.message
|
||||
if (message && /locked/i.test(message)) {
|
||||
// Payload lockout: maxLoginAttempts 5 / lockTime 15min (Users.ts).
|
||||
setStatus('تم تجاوز عدد المحاولات المسموح. حاول بعد ١٥ دقيقة.')
|
||||
return
|
||||
}
|
||||
setStatus('تعذّر تسجيل الدخول — تحقّق من البريد وكلمة المرور.')
|
||||
} catch {
|
||||
setStatus('تعذّر الاتصال. حاول تسجيل الدخول مرة أخرى.')
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { TOPICS, topicsFor, INTEREST_MAP } from '@/lib/topics'
|
||||
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -46,6 +47,19 @@ export default async function HomePage() {
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page v2 v2-home">
|
||||
{/* Organization structured data (home page only; WebSite LD lives in layout). */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'أنور بآي',
|
||||
url: process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai',
|
||||
logo: `${process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'}/brand/icon-256.png`,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Topnav viewer={viewer} />
|
||||
<main id="content">
|
||||
<section className="home-hero">
|
||||
@@ -91,7 +105,7 @@ export default async function HomePage() {
|
||||
<ol className="paths" role="list">
|
||||
{TOPICS.map(topic => (
|
||||
<li key={topic.key}>
|
||||
<Link href={`/articles?topic=${topic.key}`} className="path">
|
||||
<Link href={`/topics/${topic.key}`} className="path">
|
||||
<span className="path-name">{topic.label}</span>
|
||||
<span className="path-hint">{topic.hint}</span>
|
||||
<span className="path-count">{guidesLabel(counts[topic.key])}</span>
|
||||
@@ -136,7 +150,14 @@ export default async function HomePage() {
|
||||
<p>الأدلة المجانية كاملة دائماً. العضوية تضيف الأدلة المعمّقة، القوالب الجاهزة للتنزيل، والنقاش مع الفريق.</p>
|
||||
</div>
|
||||
<div className="membership-offer">
|
||||
{freeEarlyAccess() ? (
|
||||
<>
|
||||
<p className="price price-early">وصول مبكر مجاني</p>
|
||||
<p className="early-line">يمكنك قراءة المحتوى المنشور خلال الوصول المبكر دون بطاقة دفع.</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> <span>شهرياً، وتلغي متى شئت</span></p>
|
||||
)}
|
||||
<ul role="list">
|
||||
<li>{guidesLabel(docs.length - freeCount)} للأعضاء، وتُضاف أدلة جديدة باستمرار</li>
|
||||
<li>قوالب وقوائم فحص قابلة للتنزيل</li>
|
||||
|
||||
@@ -8,7 +8,9 @@ import PostExtras from './post-extras'
|
||||
import { LikeButton, ViewPing } from './engagement'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
import { TOPICS, topicsFor } from '@/lib/topics'
|
||||
import { formatDate, formatNumber, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import { formatDate, formatNumber, minutesLabel, contentTypeLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
import { extractH2s, innerText, type Node } from '@/lib/lexical'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -16,21 +18,36 @@ export const dynamic = 'force-dynamic'
|
||||
export async function generateMetadata({ params }: Args) {
|
||||
const { slug } = await params
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
// Drafts must never leak title/description into metadata.
|
||||
const res = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 0,
|
||||
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
select: { title: true, dek: true, publishedAt: true },
|
||||
select: { title: true, dek: true, publishedAt: true, updatedAt: true, cover: true },
|
||||
})
|
||||
const post = res.docs[0] as unknown as { title: string; dek: string; publishedAt?: string | null } | undefined
|
||||
return post
|
||||
? {
|
||||
const post = res.docs[0] as unknown as
|
||||
| { title: string; dek: string; publishedAt?: string | null; updatedAt?: string | null; cover?: { url?: string | null } | number }
|
||||
| undefined
|
||||
if (!post) return { title: 'أنور بآي' }
|
||||
|
||||
const ogImage =
|
||||
post.cover && typeof post.cover === 'object' && post.cover.url ? post.cover.url : '/og-default.png'
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.dek,
|
||||
openGraph: { type: 'article', title: post.title, description: post.dek, publishedTime: post.publishedAt ?? undefined },
|
||||
openGraph: {
|
||||
// Next replaces (not merges) the layout-level openGraph — redeclare fully.
|
||||
type: 'article',
|
||||
siteName: 'أنور بآي',
|
||||
locale: 'ar',
|
||||
title: post.title,
|
||||
description: post.dek,
|
||||
publishedTime: post.publishedAt ?? undefined,
|
||||
images: [{ url: ogImage, width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: { card: 'summary_large_image', title: post.title, description: post.dek, images: [ogImage] },
|
||||
}
|
||||
: { title: 'أنور بآي' }
|
||||
}
|
||||
|
||||
type Args = { params: Promise<{ slug: string }> }
|
||||
@@ -47,10 +64,13 @@ type PostDoc = {
|
||||
likes?: number | null
|
||||
readingMinutes: number
|
||||
publishedAt?: string | null
|
||||
updatedAt?: string | null
|
||||
teaser?: string | null
|
||||
content?: unknown
|
||||
cover?: { url?: string | null }
|
||||
attachments?: (number | { id: number })[]
|
||||
contentType?: string | null
|
||||
authorName?: string | null
|
||||
}
|
||||
|
||||
async function isLikedByViewer(postId: number, viewerId?: number): Promise<boolean> {
|
||||
@@ -83,8 +103,9 @@ export default async function PostPage({ params }: Args) {
|
||||
select: {
|
||||
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
||||
views: true, likes: true,
|
||||
readingMinutes: true, publishedAt: true, teaser: true, cover: true,
|
||||
readingMinutes: true, publishedAt: true, updatedAt: true, teaser: true, cover: true,
|
||||
content: true, attachments: true,
|
||||
contentType: true, authorName: true,
|
||||
},
|
||||
})
|
||||
const post = res.docs[0] as unknown as PostDoc | undefined
|
||||
@@ -95,7 +116,10 @@ export default async function PostPage({ params }: Args) {
|
||||
const role = viewer?.role ?? null
|
||||
const viewerId = viewer?.id
|
||||
const viewerLevel = viewer?.level ?? null
|
||||
const unlocked = post.access === 'free' || role === 'vip' || role === 'admin'
|
||||
// L-02: free early access unlocks every published post (the query above
|
||||
// already filters status: 'published'); paid mode keeps the tier gate.
|
||||
const earlyAccessNotice = freeEarlyAccess() && post.access === 'vip'
|
||||
const unlocked = freeEarlyAccess() || post.access === 'free' || role === 'vip' || role === 'admin'
|
||||
const postTopics = topicsFor(post)
|
||||
// The downloadable templates are agent-workflow files; only offer them on agent guides.
|
||||
const hasAgentFiles = postTopics.includes('agents')
|
||||
@@ -162,16 +186,54 @@ export default async function PostPage({ params }: Args) {
|
||||
})
|
||||
: null
|
||||
|
||||
// TOC from content h2s (server-extracted from the Lexical tree)
|
||||
const toc = unlocked ? extractH2s(post.content) : []
|
||||
// TOC from content h2s (server-extracted from the Lexical tree) — computed
|
||||
// unconditionally; the paywall preview (paid mode) also uses it.
|
||||
const toc = extractH2s(post.content)
|
||||
|
||||
const topic = postTopics[0]
|
||||
const topicLabel = TOPICS.find(t => t.key === topic)?.label ?? post.category
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page v2 v2-article page-posts">
|
||||
<ViewPing slug={post.slug} />
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', headline: post.title, datePublished: post.publishedAt ?? undefined, author: { '@type': 'Organization', name: 'أنور بآي' }, inLanguage: 'ar' }) }} />
|
||||
{/* L-09: honest Article + Breadcrumb structured data. JSON-LD array = multiple objects in one script. */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify([
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: post.title,
|
||||
description: post.dek,
|
||||
image: typeof post.cover === 'object' && post.cover?.url ? post.cover.url : undefined,
|
||||
datePublished: post.publishedAt ?? undefined,
|
||||
dateModified: post.updatedAt ?? post.publishedAt ?? undefined,
|
||||
author: post.authorName
|
||||
? { '@type': 'Person', name: post.authorName }
|
||||
: { '@type': 'Organization', name: 'أنور بآي' },
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'أنور بآي',
|
||||
logo: { '@type': 'ImageObject', url: `${siteUrl}/brand/icon-256.png` },
|
||||
},
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': `${siteUrl}/posts/${post.slug}` },
|
||||
isAccessibleForFree: post.access === 'free' || freeEarlyAccess(),
|
||||
inLanguage: 'ar',
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'الرئيسية', item: siteUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: topicLabel, item: `${siteUrl}/topics/${topic}` },
|
||||
{ '@type': 'ListItem', position: 3, name: post.title, item: `${siteUrl}/posts/${post.slug}` },
|
||||
],
|
||||
},
|
||||
]),
|
||||
}}
|
||||
/>
|
||||
<Topnav viewer={viewer} />
|
||||
<main id="content">
|
||||
<header className="art-head">
|
||||
@@ -190,9 +252,14 @@ export default async function PostPage({ params }: Args) {
|
||||
{post.access === 'free' ? 'مجاني بالكامل' : unlocked ? 'للأعضاء' : 'معاينة مجانية'}
|
||||
</span>
|
||||
<span>{post.category}</span>
|
||||
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
|
||||
{post.authorName && <span>بقلم {post.authorName}</span>}
|
||||
<span>{minutesLabel(post.readingMinutes)} للقراءة</span>
|
||||
<span>{post.level ?? 'مبتدئ'}</span>
|
||||
{post.publishedAt && <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>}
|
||||
{post.updatedAt && post.publishedAt && new Date(post.updatedAt) > new Date(post.publishedAt) && (
|
||||
<span>· حُدّث {formatDate(post.updatedAt)}</span>
|
||||
)}
|
||||
{(post.level ?? 'مبتدئ') === viewerLevel && <span className="for-you">يناسب مستواك</span>}
|
||||
</div>
|
||||
<div className="art-actions">
|
||||
@@ -211,7 +278,14 @@ export default async function PostPage({ params }: Args) {
|
||||
<div className="wrap art-layout">
|
||||
<article className="art-body">
|
||||
{unlocked ? (
|
||||
<>
|
||||
{earlyAccessNotice && (
|
||||
<section className="early-notice" aria-label="إشعار الوصول المبكر">
|
||||
<p>متاح مجاناً خلال الوصول المبكر. هذا المحتوى مخصص للعضوية لاحقاً؛ سنعلن موعد التفعيل قبل تغيير الوصول.</p>
|
||||
</section>
|
||||
)}
|
||||
<RichText data={post.content} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(post.teaser || '').split(/\n{2,}/).map((para, i, arr) => (
|
||||
@@ -222,11 +296,17 @@ export default async function PostPage({ params }: Args) {
|
||||
<section className="paywall" aria-labelledby="paywall-title">
|
||||
<h2 id="paywall-title">بقية هذا الدليل للأعضاء</h2>
|
||||
<p>قرأت المقدمة المجانية. العضوية تفتح بقية الأقسام العملية{hasAgentFiles ? '، وقالب حالة الوكيل وقائمة الفحص قبل التشغيل' : ''}، وكل الأدلة الحصرية الأخرى.</p>
|
||||
{toc.length > 0 && (
|
||||
<div className="paywall-preview">
|
||||
<p className="aside-title">ماذا ينتظرك داخل هذا الدليل:</p>
|
||||
<ul role="list">{toc.map((t, i) => <li key={i}><LockIcon />{t}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="paywall-actions">
|
||||
<a className="btn btn-primary" href="/subscription">اشترك بـ <bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi> شهرياً</a>
|
||||
<a className="btn btn-quiet" href={`/login?next=/posts/${post.slug}`}>لدي عضوية، تسجيل الدخول</a>
|
||||
</div>
|
||||
<p className="fine">تلغي في أي وقت من صفحة حسابك.</p>
|
||||
<p className="fine">تلغي في أي وقت من صفحة حسابك. <Link href="/subscription#faq">أسئلة قبل الاشتراك</Link> · <Link href="/articles?access=free">أكمل بالأدلة المجانية</Link></p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -343,7 +423,7 @@ function SaveButton() {
|
||||
}
|
||||
|
||||
// Minimal Lexical/JSON renderer — headings, paragraphs, lists, quotes, code, rules.
|
||||
type Node = { type?: string; text?: string; children?: Node[]; tag?: string; url?: string; listType?: string; code?: string }
|
||||
// `Node`, `innerText` and `extractH2s` live in lib/lexical (shared with llms routes).
|
||||
|
||||
function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): React.ReactNode[] {
|
||||
return (nodes || []).map((n, i) => {
|
||||
@@ -370,27 +450,19 @@ function renderChildren(nodes: Node[] | undefined, counter: { h2: number }): Rea
|
||||
case 'paragraph': return <p key={key}>{inner}</p>
|
||||
case 'code': return <pre key={key} aria-label="مثال برمجي"><code>{n.code ?? innerText(n.children)}</code></pre>
|
||||
case 'horizontalrule': return <hr key={key} />
|
||||
case 'table':
|
||||
return (
|
||||
<div key={key} className="post-table" role="region" aria-label="جدول" tabIndex={0}>
|
||||
<table>{inner}</table>
|
||||
</div>
|
||||
)
|
||||
case 'tablerow': return <tr key={key}>{inner}</tr>
|
||||
case 'tablecell': return n.headerState ? <th key={key}>{inner}</th> : <td key={key}>{inner}</td>
|
||||
default: return <p key={key}>{inner}</p>
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function innerText(nodes?: Node[]): string {
|
||||
return (nodes || []).map(n => (n.type === 'text' ? n.text || '' : innerText(n.children))).join('')
|
||||
}
|
||||
|
||||
function extractH2s(data: unknown): string[] {
|
||||
try {
|
||||
const root = (data as { root?: { children?: Node[] } })?.root
|
||||
if (!root?.children) return []
|
||||
return root.children
|
||||
.filter(n => n.type === 'heading' && (n.tag || 'h2') === 'h2')
|
||||
.map(n => innerText(n.children))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function RichText({ data }: { data: unknown }) {
|
||||
if (!data || typeof data !== 'object' || !('root' in data)) return null
|
||||
const rootUnknown = data.root
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = { title: 'إنشاء حساب' }
|
||||
export const metadata: Metadata = {
|
||||
title: 'إنشاء حساب',
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default function SignupLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
|
||||
@@ -14,11 +14,29 @@ import '../styles/signup.css'
|
||||
export default function SignupPage() {
|
||||
const router = useRouter()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [status, setStatus] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailError, setEmailError] = useState<string | null>(null)
|
||||
const [suggestion, setSuggestion] = useState<string | null>(null)
|
||||
const [pwOk, setPwOk] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [nextPath, setNextPath] = useState<string | null>(null)
|
||||
|
||||
/* Common typos → canonical domain (step 16; on blur of the email input). */
|
||||
const EMAIL_TYPOS: Record<string, string> = {
|
||||
'gmial.com': 'gmail.com',
|
||||
'gmai.com': 'gmail.com',
|
||||
'gmail.co': 'gmail.com',
|
||||
'hotmial.com': 'hotmail.com',
|
||||
'outlook.co': 'outlook.com',
|
||||
'yahoo.co': 'yahoo.com',
|
||||
}
|
||||
function domainSuggestion(raw: string): string | null {
|
||||
const at = raw.lastIndexOf('@')
|
||||
if (at < 0) return null
|
||||
return EMAIL_TYPOS[raw.slice(at + 1).toLowerCase().trim()] ?? null
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setNextPath(safeNext(new URLSearchParams(window.location.search).get('next')))
|
||||
}, [])
|
||||
@@ -27,10 +45,9 @@ export default function SignupPage() {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const data = new FormData(form)
|
||||
const confirm = form.elements.namedItem('confirmPassword') as HTMLInputElement
|
||||
confirm.setCustomValidity(data.get('password') === data.get('confirmPassword') ? '' : 'كلمتا المرور غير متطابقتان.')
|
||||
setEmailError(null)
|
||||
if (!form.checkValidity()) {
|
||||
setStatus(data.get('password') !== data.get('confirmPassword') ? 'كلمتا المرور غير متطابقتان.' : 'تحقق من جميع الحقول المطلوبة.')
|
||||
setStatus('تحقق من جميع الحقول المطلوبة.')
|
||||
form.reportValidity()
|
||||
return
|
||||
}
|
||||
@@ -43,8 +60,29 @@ export default function SignupPage() {
|
||||
body: JSON.stringify({ email: data.get('email'), password: data.get('password'), fullName: data.get('name'), termsVersion: '2026-10-03' }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null)
|
||||
setStatus(err?.errors?.[0]?.message || 'تعذّر إنشاء الحساب — قد يكون البريد مستخدماً بالفعل.')
|
||||
const err = await res.json().catch(() => null) as { error?: string; errors?: { message?: string; path?: string; field?: string }[] } | null
|
||||
// Middleware 429 shape: { error: 'محاولات كثيرة جدًا. حاول بعد قليل.' }
|
||||
// + Retry-After seconds → show verbatim, append «حاول بعد X دقيقة.»
|
||||
const retryAfterHeader = res.headers.get('Retry-After')
|
||||
if (typeof err?.error === 'string') {
|
||||
const retryAfterSec = Number(retryAfterHeader)
|
||||
setStatus(
|
||||
retryAfterHeader && Number.isFinite(retryAfterSec)
|
||||
? `${err.error} حاول بعد ${Math.max(1, Math.ceil(retryAfterSec / 60))} دقيقة.`
|
||||
: err.error,
|
||||
)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
const first = err?.errors?.[0]
|
||||
const failedField = first?.path ?? first?.field
|
||||
if (failedField === 'email' && first?.message) {
|
||||
// Duplicate email (unique-constraint ValidationError) → inline under
|
||||
// the email input, not the global status line.
|
||||
setEmailError('هذا البريد مسجَّل لدينا.')
|
||||
} else {
|
||||
setStatus(first?.message || 'تعذّر إنشاء الحساب — قد يكون البريد مستخدماً بالفعل.')
|
||||
}
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
@@ -85,11 +123,20 @@ export default function SignupPage() {
|
||||
<h2 data-od-id="signup-title">إنشاء حساب</h2>
|
||||
<p className="lead">أدخل بياناتك الأساسية للبدء.</p>
|
||||
<div className="field"><label htmlFor="name">الاسم</label><input className="input" id="name" name="name" type="text" autoComplete="name" minLength={2} required onInvalid={e => e.currentTarget.setCustomValidity('أدخل اسمك.')} onInput={e => e.currentTarget.setCustomValidity('')} /></div>
|
||||
<div className="field"><label htmlFor="email">البريد الإلكتروني</label><input className="input" id="email" name="email" type="email" inputMode="email" autoComplete="email" placeholder="name@example.com" dir="ltr" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل بريداً إلكترونياً صحيحاً.')} onInput={e => e.currentTarget.setCustomValidity('')} /></div>
|
||||
<div className="field"><label htmlFor="password">كلمة المرور</label><div className="input-wrap"><input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="new-password" minLength={8} aria-describedby="passwordHint" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل كلمة مرور من ثمانية أحرف على الأقل.')} onInput={e => e.currentTarget.setCustomValidity('')} /><button className="password-toggle" type="button" aria-controls="password" onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button></div><span className="fine" id="passwordHint">ثمانية أحرف على الأقل.</span></div>
|
||||
<div className="field"><label htmlFor="confirmPassword">تأكيد كلمة المرور</label><div className="input-wrap"><input className="input" id="confirmPassword" name="confirmPassword" type={showConfirm ? 'text' : 'password'} autoComplete="new-password" minLength={8} required onInvalid={e => e.currentTarget.setCustomValidity('أعد إدخال كلمة المرور.')} onInput={e => e.currentTarget.setCustomValidity('')} /><button className="password-toggle" type="button" aria-controls="confirmPassword" onClick={() => setShowConfirm(v => !v)}>{showConfirm ? 'إخفاء' : 'إظهار'}</button></div></div>
|
||||
<div className="field"><label htmlFor="email">البريد الإلكتروني</label><input className="input" id="email" name="email" type="email" inputMode="email" autoComplete="email" placeholder="name@example.com" dir="ltr" required value={email} onChange={e => setEmail(e.target.value)} onBlur={e => setSuggestion(domainSuggestion(e.target.value))} onInvalid={e => e.currentTarget.setCustomValidity('أدخل بريداً إلكترونياً صحيحاً.')} onInput={e => e.currentTarget.setCustomValidity('')} />
|
||||
{emailError && (
|
||||
<p className="field-error" role="alert">هذا البريد مسجَّل لدينا. <Link href="/login?next=/verify-email">تسجيل الدخول</Link></p>
|
||||
)}
|
||||
{!emailError && suggestion && (
|
||||
<button type="button" className="typo-suggest" onClick={() => { const at = email.lastIndexOf('@'); setEmail(`${email.slice(0, at + 1)}${suggestion}`); setSuggestion(null) }}>
|
||||
هل تقصد <bdi dir="ltr">{suggestion}</bdi>؟
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="field"><label htmlFor="password">كلمة المرور</label><div className="input-wrap"><input className="input" id="password" name="password" type={showPassword ? 'text' : 'password'} autoComplete="new-password" minLength={8} aria-describedby="passwordHint" required onInvalid={e => e.currentTarget.setCustomValidity('أدخل كلمة مرور من ثمانية أحرف على الأقل.')} onInput={e => { e.currentTarget.setCustomValidity(''); setPwOk(e.currentTarget.value.length >= 8) }} /><button className="password-toggle" type="button" aria-controls="password" onClick={() => setShowPassword(v => !v)}>{showPassword ? 'إخفاء' : 'إظهار'}</button></div><span className={`fine${pwOk ? ' ok' : ''}`} id="passwordHint">ثمانية أحرف على الأقل.</span></div>
|
||||
<label className="check"><input id="terms" name="terms" type="checkbox" required /><span>أوافق على <Link href="/privacy">سياسة الخصوصية</Link> و<Link href="/terms">شروط الاستخدام</Link>.</span></label>
|
||||
<button className="btn btn-primary" type="submit" disabled={busy} data-od-id="create-account-cta">{busy ? 'جارٍ إنشاء الحساب…' : 'أنشئ حسابي'}</button>
|
||||
<p className="fine signup-reassure">مجاناً — بلا بطاقة ائتمانية. لن نشارك بريدك أبداً.</p>
|
||||
<div className="status" id="signupStatus" role="status" aria-live="polite">{status}</div>
|
||||
<p className="signin">لديك حساب بالفعل؟ <Link href={nextPath ? `/login?next=${encodeURIComponent(nextPath)}` : '/login'}>سجّل الدخول</Link></p>
|
||||
</form>
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
.page-account-settings .plan-details dt{font:12px/1.6 var(--font-mono);color:var(--muted)}
|
||||
.page-account-settings .plan-details dd{margin:4px 0 0;font-size:16px}
|
||||
.page-account-settings .price-tag{font-family:var(--font-display);font-size:44px;line-height:1}
|
||||
.page-account-settings .price-tag-early{font-size:24px;color:var(--accent,var(--fg))}
|
||||
.page-account-settings .price-tag small{display:block;font:11px/1.5 var(--font-mono);color:var(--muted);margin-top:6px}
|
||||
.page-account-settings .membership-actions{display:flex;gap:14px;flex-wrap:wrap;padding-top:22px;border-top:1px solid var(--border)}
|
||||
.page-account-settings .cancel-zone{margin-top:34px;max-width:640px;border-top:1px solid var(--border);padding-top:24px}
|
||||
|
||||
@@ -5,4 +5,9 @@
|
||||
.page-verify-email .verification{min-height:calc(100vh - 70px);display:grid;place-items:center;padding-block:clamp(54px,9vw,110px)}.page-verify-email .card{width:min(700px,100%);background:color-mix(in oklch,var(--surface) 92%,transparent);border:1px solid var(--border);padding:clamp(28px,6vw,64px)}.page-verify-email .eyebrow{font:11px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}.page-verify-email .card h1{font-size:clamp(42px,7vw,72px);line-height:1.08;margin:0;max-width:11ch}.page-verify-email .lead{font-size:clamp(18px,2vw,21px);color:var(--muted);max-width:54ch;margin:22px 0 0}.page-verify-email .email{display:block;color:var(--fg);font:700 16px/1.6 var(--font-mono);direction:ltr;text-align:right;overflow-wrap:anywhere;margin-top:8px}.page-verify-email .steps{list-style:none;margin:36px 0;padding:0;border-top:1px solid var(--fg)}.page-verify-email .steps li{display:grid;grid-template-columns:34px 1fr;gap:14px;padding:17px 0;border-bottom:1px solid var(--border)}.page-verify-email .step-num{font:12px/1.8 var(--font-mono);color:var(--accent)}.page-verify-email .actions{display:flex;gap:12px;flex-wrap:wrap}.page-verify-email .btn{min-height:50px;display:inline-flex;align-items:center;justify-content:center;padding:12px 22px;border:1px solid transparent;font-weight:700}.page-verify-email .btn-primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}.page-verify-email .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black);color:var(--bg)}.page-verify-email .btn-secondary{background:transparent;color:var(--fg);border-color:var(--border)}.page-verify-email .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}.page-verify-email .status{min-height:27px;color:var(--muted);font-size:14px;margin:16px 0 0}.page-verify-email .fine{color:var(--muted);font-size:12px;margin:24px 0 0;padding-top:20px;border-top:1px solid var(--border)}
|
||||
@media(max-width:620px){.page-verify-email .nav-inner{gap:10px}.page-verify-email .logo{font-size:20px}.page-verify-email .nav-return{font-size:13px}.page-verify-email .verification{align-items:start;padding-block:34px}.page-verify-email .card{padding:28px 20px}.page-verify-email .actions{align-items:stretch}.page-verify-email .actions .btn{width:100%}}
|
||||
@media(prefers-reduced-motion:reduce){.page-verify-email *{transition-duration:.01ms!important}}
|
||||
.page-verify-email .change-email{margin-top:18px}
|
||||
.page-verify-email .change-email summary{cursor:pointer;font-size:13px;color:var(--muted)}
|
||||
.page-verify-email .change-email summary:hover{color:var(--fg)}
|
||||
.page-verify-email .change-email-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px;align-items:center}
|
||||
.page-verify-email .change-email-row .input{min-height:48px;border:1px solid color-mix(in oklch,var(--border) 72%,var(--fg));background:var(--surface);color:var(--fg);padding:0 14px;min-width:min(300px,100%)}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */
|
||||
.page-posts *,.page-posts *::before,.page-posts *::after{box-sizing:border-box} .page-posts{scroll-behavior:smooth;-webkit-text-size-adjust:100%} .page-posts{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} .page-posts.locked{overflow:hidden}
|
||||
.page-posts a{color:inherit;text-decoration:none} .page-posts button,.page-posts input,.page-posts textarea{font:inherit} .page-posts button{cursor:pointer} .page-posts h1,.page-posts h2,.page-posts h3{font-family:var(--font-display);text-wrap:balance} .page-posts p{text-wrap:pretty} .page-posts :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-posts .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-posts .topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .page-posts .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.page-posts .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-posts .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .page-posts .nav-links{display:flex;align-items:center;gap:28px}.page-posts .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-posts .nav-links a:hover{color:var(--fg)}
|
||||
.page-posts .nav-tools{display:flex;align-items:center;gap:8px}.page-posts .nav-login,.page-posts .icon-btn{min-height:44px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.page-posts .nav-login{padding-inline:16px}.page-posts .nav-login:hover,.page-posts .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.page-posts .icon-btn{width:46px}
|
||||
.page-posts .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
|
||||
.page-posts .demo-strip b{color:var(--surface);font-weight:700}
|
||||
.page-posts .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
|
||||
.page-posts .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
|
||||
.page-posts .lock-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--fg);padding:6px 14px;font:12px/1.5 var(--font-mono);color:var(--fg);margin-bottom:22px}
|
||||
.page-posts .lock-chip svg{width:14px;height:14px}
|
||||
.page-posts .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
|
||||
.page-posts .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
|
||||
.page-posts .article-meta{display:flex;align-items:center;flex-wrap:wrap;gap:14px 24px;margin-top:34px;padding-top:20px;border-top:1px solid var(--border);font:12px/1.5 var(--font-mono);color:var(--muted)}
|
||||
.page-posts .article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}
|
||||
.page-posts .article-body{font-size:19px;line-height:2}
|
||||
.page-posts .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
|
||||
.page-posts .article-body p{margin:0 0 26px}
|
||||
.page-posts .lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
|
||||
.page-posts .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
|
||||
.page-posts .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
|
||||
.page-posts .free-tag{font:11px/1.5 var(--font-mono);color:var(--muted);margin-bottom:6px;display:block}
|
||||
.page-posts .lock-cut{position:relative}
|
||||
.page-posts .lock-cut::after{content:"";position:absolute;inset-inline:0;bottom:0;height:120px;background:linear-gradient(to bottom,transparent,var(--bg) 88%);pointer-events:none}
|
||||
.page-posts .lock-cut .cut-p{margin-bottom:0}
|
||||
.page-posts .paywall{margin-top:-30px;position:relative;border:1px solid var(--fg);background:var(--surface);padding:clamp(28px,4vw,44px);z-index:2}
|
||||
.page-posts .paywall .crumb{margin-bottom:12px}
|
||||
.page-posts .paywall h2{font-size:clamp(30px,3.6vw,44px);line-height:1.18;margin:0 0 14px;max-width:16ch}
|
||||
.page-posts .paywall p{color:var(--muted);max-width:56ch;margin:0 0 24px}
|
||||
.page-posts .locked-in{border-top:1px solid var(--fg);margin:0 0 26px}
|
||||
.page-posts .locked-in li{display:flex;align-items:center;gap:12px;min-height:52px;border-bottom:1px solid var(--border);color:var(--muted);font-size:16px}
|
||||
.page-posts .locked-in svg{width:14px;height:14px;flex:none}
|
||||
.page-posts .locked-in .num{margin-inline-start:auto;font:12px/1.5 var(--font-mono)}
|
||||
.page-posts .paywall-actions{display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||||
.page-posts .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
|
||||
.page-posts .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}
|
||||
.page-posts .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
|
||||
.page-posts .btn-secondary{border-color:var(--border);background:transparent;color:var(--fg)}
|
||||
.page-posts .btn-secondary:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-posts .text-link{font-weight:700;color:var(--fg);text-decoration:underline;text-underline-offset:4px;min-height:44px;display:inline-flex;align-items:center}
|
||||
.page-posts .text-link:hover{color:var(--accent)}
|
||||
.page-posts .price-line{font:12px/1.6 var(--font-mono);color:var(--muted);margin-top:18px}
|
||||
.page-posts .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
|
||||
.page-posts .toc strong{font-family:var(--font-display);font-size:20px}
|
||||
.page-posts .toc a,.page-posts .toc .toc-locked{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}
|
||||
.page-posts .toc a:hover{color:var(--fg)}
|
||||
.page-posts .toc .toc-locked{color:color-mix(in oklch,var(--muted) 60%,transparent)}
|
||||
.page-posts .toc .toc-locked svg{width:12px;height:12px;flex:none}
|
||||
.page-posts .member-note{margin-top:28px;border:1px solid var(--fg);padding:22px}
|
||||
.page-posts .member-note strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:8px}
|
||||
.page-posts .member-note p{color:var(--muted);font-size:14px;margin:0 0 16px}
|
||||
.page-posts .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
|
||||
.page-posts .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
|
||||
.page-posts .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
|
||||
.page-posts .footer-col a{display:block;min-height:36px}
|
||||
.page-posts .footer-col a:hover{color:var(--fg);text-decoration:underline}
|
||||
.page-posts .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
|
||||
.page-posts .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-posts .menu-panel.open{visibility:visible;pointer-events:auto}.page-posts .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-posts .menu-panel.open .menu-scrim{opacity:1}.page-posts .menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.page-posts .menu-panel.open .menu-drawer{transform:translateX(0)}.page-posts .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-posts .menu-links{display:flex;flex-direction:column}.page-posts .menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px}
|
||||
@media(max-width:920px){.page-posts .nav-links{display:none}.page-posts .article-layout{grid-template-columns:1fr}.page-posts .toc{position:static;order:0;margin-top:24px}.page-posts .footer-grid{grid-template-columns:1fr 1fr}.page-posts .article-body{font-size:18px}}
|
||||
@media(max-width:620px){.page-posts .nav-login{display:none}.page-posts .article-head h1{font-size:46px}.page-posts .article-layout{padding-block:56px}.page-posts .footer-grid{grid-template-columns:1fr;gap:28px}.page-posts .paywall-actions{flex-direction:column;align-items:stretch}.page-posts .paywall-actions .btn{width:100%}}
|
||||
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
@@ -1,62 +0,0 @@
|
||||
/* Page-scoped styles: .page-posts. Shared tokens live in globals.css. */
|
||||
.page-posts *,.page-posts *::before,.page-posts *::after{box-sizing:border-box} .page-posts{scroll-behavior:smooth;-webkit-text-size-adjust:100%} .page-posts{margin:0;background:var(--bg);color:var(--fg);font:16px/1.75 var(--font-body);text-rendering:optimizeLegibility} .page-posts.locked{overflow:hidden}
|
||||
.page-posts a{color:inherit;text-decoration:none} .page-posts button,.page-posts input,.page-posts textarea{font:inherit} .page-posts button{cursor:pointer} .page-posts h1,.page-posts h2,.page-posts h3{font-family:var(--font-display);text-wrap:balance} .page-posts p{text-wrap:pretty} .page-posts :focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 55%,transparent);outline-offset:3px}
|
||||
.page-posts .container{max-width:var(--container);margin-inline:auto;padding-inline:var(--gutter)}
|
||||
.page-posts .topnav{position:sticky;top:0;z-index:20;background:color-mix(in oklch,var(--bg) 92%,transparent);backdrop-filter:blur(14px);border-bottom:1px solid var(--border)} .page-posts .nav-inner{min-height:70px;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||
.page-posts .logo{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-display);font-size:23px;font-weight:700}.page-posts .logo-mark{width:12px;height:12px;background:var(--fg);transform:rotate(45deg)} .page-posts .nav-links{display:flex;align-items:center;gap:28px}.page-posts .nav-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--muted)}.page-posts .nav-links a:hover{color:var(--fg)}
|
||||
.page-posts .nav-tools{display:flex;align-items:center;gap:8px}
|
||||
.page-posts .nav-account{min-height:44px;display:inline-flex;align-items:center;gap:10px;padding-inline:16px;border:1px solid var(--fg);background:var(--fg);color:var(--surface);font-weight:700}.page-posts .nav-account:hover{background:var(--accent);border-color:var(--accent)}
|
||||
.page-posts .nav-account .avatar{width:22px;height:22px;background:var(--surface);color:var(--fg);display:grid;place-items:center;font:12px/1 var(--font-mono);font-weight:700}
|
||||
.page-posts .icon-btn{min-height:44px;width:46px;border:1px solid var(--border);background:transparent;color:var(--fg);display:inline-grid;place-items:center}.page-posts .icon-btn:hover{border-color:var(--fg);background:var(--fg-soft)}
|
||||
.page-posts .demo-strip{background:var(--fg);color:var(--surface);font:11px/1.6 var(--font-mono);padding:8px 0;text-align:center}
|
||||
.page-posts .demo-strip b{color:var(--surface);font-weight:700}
|
||||
.page-posts .article-head{padding-block:clamp(58px,9vw,100px);border-bottom:1px solid var(--border)}
|
||||
.page-posts .crumb{font:12px/1.5 var(--font-mono);color:var(--accent);margin:0 0 20px}
|
||||
.page-posts .open-chip{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--accent);color:var(--accent);padding:6px 14px;font:12px/1.5 var(--font-mono);margin-bottom:22px}
|
||||
.page-posts .article-head h1{font-size:clamp(46px,7vw,88px);line-height:1.06;max-width:12ch;margin:0}
|
||||
.page-posts .dek{font-size:clamp(19px,2vw,24px);line-height:1.7;color:var(--muted);max-width:58ch;margin:28px 0 0}
|
||||
.page-posts .article-meta{display:flex;align-items:center;flex-wrap:wrap;gap:14px 24px;margin-top:34px;padding-top:20px;border-top:1px solid var(--border);font:12px/1.5 var(--font-mono);color:var(--muted)}
|
||||
.page-posts .save-btn{min-height:40px;padding:6px 14px;border:1px solid var(--border);background:transparent;color:var(--fg);font:12px/1.5 var(--font-mono)}.page-posts .save-btn:hover{border-color:var(--fg);background:var(--fg-soft)}.page-posts .save-btn[aria-pressed="true"]{border-color:var(--accent);color:var(--accent)}
|
||||
.page-posts .article-layout{display:grid;grid-template-columns:minmax(0,740px) 240px;gap:clamp(48px,8vw,100px);align-items:start;padding-block:80px}
|
||||
.page-posts .article-body{font-size:19px;line-height:2}
|
||||
.page-posts .article-body h2{font-size:clamp(32px,4vw,48px);line-height:1.2;margin:64px 0 20px}
|
||||
.page-posts .article-body h3{font-size:27px;margin:42px 0 14px}
|
||||
.page-posts .article-body p{margin:0 0 26px}
|
||||
.page-posts .article-body ul{padding-inline-start:24px}.page-posts .article-body li{margin-block:10px}
|
||||
.page-posts .lede{font-family:var(--font-display);font-size:28px;line-height:1.6}
|
||||
.page-posts .code{direction:ltr;text-align:left;background:var(--fg);color:var(--surface);padding:26px;overflow:auto;font:14px/1.8 var(--font-mono);margin:34px 0}
|
||||
.page-posts .note{border-block:1px solid var(--fg);padding:28px 0;margin:42px 0}
|
||||
.page-posts .note strong{display:block;font-family:var(--font-display);font-size:22px;margin-bottom:8px}
|
||||
.page-posts .steps{counter-reset:step;list-style:none;padding:0}.page-posts .steps li{counter-increment:step;display:grid;grid-template-columns:56px 1fr;gap:18px;padding:22px 0;border-bottom:1px solid var(--border)}.page-posts .steps li::before{content:"٠" counter(step,arabic-indic);font-family:var(--font-display);font-size:34px;color:var(--accent);line-height:1}
|
||||
.page-posts .steps strong{display:block;font-size:20px;margin-bottom:6px}.page-posts .steps p{margin:0;color:var(--muted)}
|
||||
.page-posts .toc{position:sticky;top:100px;border-top:1px solid var(--fg);padding-top:18px}
|
||||
.page-posts .toc strong{font-family:var(--font-display);font-size:20px}
|
||||
.page-posts .toc a{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid var(--border);color:var(--muted)}.page-posts .toc a:hover{color:var(--fg)}
|
||||
.page-posts .progress-card{margin-top:28px;padding:22px;background:var(--fg);color:var(--surface)}
|
||||
.page-posts .progress-card strong{font-size:18px;display:block;margin-bottom:12px}
|
||||
.page-posts .progress-track{height:6px;background:color-mix(in oklch,var(--surface) 25%,transparent);margin-bottom:8px}
|
||||
.page-posts .progress-bar{height:100%;background:var(--accent);width:40%}
|
||||
.page-posts .progress-card .p-label{font:11px/1.6 var(--font-mono);color:color-mix(in oklch,var(--surface) 72%,transparent)}
|
||||
.page-posts .files-card{margin-top:18px;border:1px solid var(--fg);padding:22px}
|
||||
.page-posts .files-card strong{font-family:var(--font-display);font-size:20px;display:block;margin-bottom:12px}
|
||||
.page-posts .file-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border)}
|
||||
.page-posts .file-row:last-of-type{border-bottom:0}
|
||||
.page-posts .file-row span{font-size:14px}
|
||||
.page-posts .dl-btn{min-height:38px;padding:4px 12px;border:1px solid var(--border);background:transparent;color:var(--accent);font:12px/1.5 var(--font-mono);font-weight:700}.page-posts .dl-btn:hover{border-color:var(--accent);background:var(--accent-soft)}
|
||||
.page-posts .comments{padding-block:80px;border-top:1px solid var(--border)}.page-posts .comments-wrap{max-width:740px}.page-posts .comments h2{font-size:clamp(36px,5vw,54px);margin:0}
|
||||
.page-posts .comment-list{display:grid}.page-posts .comment{padding:24px 0;border-bottom:1px solid var(--border)}.page-posts .comment-head{display:flex;justify-content:space-between;gap:16px}.page-posts .comment p{margin:8px 0 0}
|
||||
.page-posts .comment-form{display:grid;gap:18px;margin-top:36px}.page-posts .field{display:grid;gap:7px}.page-posts .field label{font-size:13px;color:var(--muted)}
|
||||
.page-posts .input,.page-posts .textarea{width:100%;border:1px solid var(--border);background:var(--surface);color:var(--fg);padding:13px 14px}.page-posts .textarea{min-height:130px;resize:vertical}
|
||||
.page-posts .btn{min-height:46px;display:inline-flex;align-items:center;justify-content:center;padding:11px 22px;border:1px solid transparent;font-weight:700}
|
||||
.page-posts .btn-primary{background:var(--accent);color:var(--surface);border-color:var(--accent)}.page-posts .btn-primary:hover{background:color-mix(in oklch,var(--accent) 86%,black)}
|
||||
.page-posts .status{font-size:14px;color:var(--muted);min-height:22px}
|
||||
.page-posts .related{padding-block:80px;border-top:1px solid var(--border)}.page-posts .related-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--fg)}.page-posts .related-card{padding:24px 0;border-bottom:1px solid var(--border)}.page-posts .related-card+.related-card{margin-inline-start:28px}.page-posts .related-card span{font:12px/1.5 var(--font-mono);color:var(--muted)}.page-posts .related-card h3{font-size:24px;margin:12px 0}.page-posts .related-card a{font-weight:700}.page-posts .related-card a:hover{color:var(--accent)}
|
||||
.page-posts .pagefoot{padding-block:54px;border-top:1px solid var(--border);color:var(--muted)}
|
||||
.page-posts .footer-grid{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:44px}
|
||||
.page-posts .footer-col strong{display:block;color:var(--fg);margin-bottom:12px}
|
||||
.page-posts .footer-col a{display:block;min-height:36px}.page-posts .footer-col a:hover{color:var(--fg);text-decoration:underline}
|
||||
.page-posts .footer-bottom{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-top:28px;margin-top:38px;border-top:1px solid var(--border);font-size:13px}
|
||||
.page-posts .menu-panel{position:fixed;inset:0;z-index:60;visibility:hidden;pointer-events:none}.page-posts .menu-panel.open{visibility:visible;pointer-events:auto}.page-posts .menu-scrim{position:absolute;inset:0;border:0;background:color-mix(in oklch,var(--fg) 55%,transparent);opacity:0}.page-posts .menu-panel.open .menu-scrim{opacity:1}.page-posts .menu-drawer{position:absolute;inset-block:0;inset-inline-start:0;width:min(520px,92vw);padding:28px clamp(24px,5vw,54px);background:var(--bg);border-inline-end:1px solid var(--fg);transform:translateX(105%);transition:transform .25s ease;display:flex;flex-direction:column}.page-posts .menu-panel.open .menu-drawer{transform:translateX(0)}.page-posts .menu-head{display:flex;justify-content:space-between;align-items:center;padding-bottom:24px;border-bottom:1px solid var(--border)}.page-posts .menu-links{display:flex;flex-direction:column}.page-posts .menu-links a{display:flex;justify-content:space-between;align-items:center;min-height:58px;border-bottom:1px solid var(--border);font-size:18px}
|
||||
@media(max-width:920px){.page-posts .nav-links{display:none}.page-posts .article-layout{grid-template-columns:1fr}.page-posts .toc{position:static;order:0;margin-top:24px}.page-posts .footer-grid{grid-template-columns:1fr 1fr}.page-posts .related-grid{grid-template-columns:1fr}.page-posts .related-card+.related-card{margin-inline-start:0}.page-posts .article-body{font-size:18px}}
|
||||
@media(max-width:620px){.page-posts .nav-account .name-label{display:none}.page-posts .article-head h1{font-size:46px}.page-posts .article-layout,.page-posts .comments,.page-posts .related{padding-block:56px}.page-posts .footer-grid{grid-template-columns:1fr;gap:28px}.page-posts .article-meta{align-items:flex-start;flex-direction:column}.page-posts .steps li{grid-template-columns:38px 1fr}}
|
||||
@media(prefers-reduced-motion:reduce){.page-posts *{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
@@ -6,3 +6,8 @@
|
||||
@media(max-width:920px){.page-signup .auth{grid-template-columns:1fr}.page-signup .auth-story{min-height:390px;padding:58px var(--gutter)}.page-signup .auth-form-wrap{padding:58px var(--gutter);justify-content:center}.page-signup .story-foot{margin-top:48px}}
|
||||
@media(max-width:620px){.page-signup .nav-inner{gap:12px}.page-signup .nav-return{font-size:13px}.page-signup .auth-story{min-height:auto}.page-signup .auth-story h1{font-size:46px}.page-signup .auth-form-wrap{padding-block:46px}.page-signup .story-list{margin-top:38px}}
|
||||
@media(prefers-reduced-motion:reduce){.page-signup *{transition-duration:.01ms!important}}
|
||||
.page-signup .fine.ok{color:var(--success,#2e9e6b)}
|
||||
.page-signup .field-error{margin:2px 0 0;font-size:13px;color:var(--error,#b4443c)}
|
||||
.page-signup .field-error a{color:inherit;text-decoration:underline}
|
||||
.page-signup .typo-suggest{justify-self:start;margin-top:4px;padding:0;border:0;background:none;color:var(--accent);font:inherit;font-size:13px;cursor:pointer;text-decoration:underline}
|
||||
.page-signup .signup-reassure{margin-top:12px}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
/* Early-access notification signup (L-01). 200 → success copy;
|
||||
503/4xx → the server's Arabic error verbatim; 5xx/network → generic fallback. */
|
||||
export default function NotifyForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [done, setDone] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (pending) return
|
||||
setPending(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/notify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
const data = (await res.json().catch(() => null)) as { error?: string; alreadySubscribed?: boolean } | null
|
||||
if (res.ok) {
|
||||
setDone(data?.alreadySubscribed ? 'بريدك مسجَّل لدينا بالفعل — سنراسلك عند تفعيل العضوية.' : 'تم! سنراسلك عند تفعيل العضوية.')
|
||||
} else {
|
||||
setError(data?.error ?? 'تعذر الحفظ، حاول مرة أخرى.')
|
||||
}
|
||||
} catch {
|
||||
setError('تعذر الحفظ، حاول مرة أخرى.')
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return <p className="notify-done" role="status">{done}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="notify-form" onSubmit={submit}>
|
||||
<label className="sr-only" htmlFor="notify-email">البريد الإلكتروني</label>
|
||||
<input
|
||||
id="notify-email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
dir="ltr"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" type="submit" disabled={pending || email.trim() === ''}>
|
||||
{pending ? 'جارٍ الحفظ…' : 'أعلمني عند تفعيل العضوية'}
|
||||
</button>
|
||||
{error && <p className="notify-error" role="alert">{error}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,15 @@ import configPromise from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import { formatNumber, guidesLabel, minutesLabel, MEMBERSHIP_PRICE } from '@/lib/format'
|
||||
import type { Viewer } from '@/lib/viewer'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
import NotifyForm from './notify-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: 'العضوية' }
|
||||
export const metadata = {
|
||||
title: 'العضوية',
|
||||
description:
|
||||
'انضم لقائمة الإشعار: وصول مبكر مجاني لكل الأدلة اليوم، وخطة عضوية مستقبلية بـ 10$ شهرياً تضيف ملفات عمل وملخصات للأعضاء.',
|
||||
}
|
||||
|
||||
/* Member-library samples — real VIP guides, read from the CMS at request time. */
|
||||
const SAMPLES = [
|
||||
@@ -31,6 +37,7 @@ export default async function SubscriptionPage() {
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
const role = (user?.role as string) || null
|
||||
const entitled = role === 'vip' || role === 'admin'
|
||||
const early = freeEarlyAccess()
|
||||
const viewer: Viewer = user ? { email: user.email, role: role ?? 'free' } : null
|
||||
const { docs: published } = await payload.find({
|
||||
collection: 'posts',
|
||||
@@ -50,6 +57,16 @@ export default async function SubscriptionPage() {
|
||||
<div id="site" className="site-page v2 v2-membership">
|
||||
<Topnav viewer={viewer} />
|
||||
<main id="content">
|
||||
{early && (
|
||||
<section className="early-access" aria-labelledby="early-title">
|
||||
<div className="wrap narrow">
|
||||
<h2 id="early-title">وصول مبكر مجاني</h2>
|
||||
<p>يمكنك قراءة المحتوى المنشور خلال فترة الوصول المبكر دون بطاقة دفع. نراجع إطلاق العضوية بعد نحو أسبوعين، وقد تمتد الفترة إذا لم تكتمل خدمة الدفع. بعض المواد محددة للعضوية مستقبلاً، وسنوضح ذلك قبل تفعيلها. لن يتم إنشاء اشتراك أو تحصيل مبلغ تلقائياً.</p>
|
||||
<NotifyForm />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mem-hero" aria-labelledby="mem-title">
|
||||
<div className="wrap mem-hero-grid">
|
||||
<div className="mem-hero-copy">
|
||||
@@ -64,15 +81,17 @@ export default async function SubscriptionPage() {
|
||||
<article className="plan" aria-labelledby="plan-title">
|
||||
<h2 id="plan-title">عضوية فردية</h2>
|
||||
<p className="plan-price"><bdi dir="ltr">{MEMBERSHIP_PRICE}</bdi><span>شهرياً</span></p>
|
||||
<p className="plan-note">تلغي متى شئت، ويبقى حسابك المجاني ومحفوظاتك.</p>
|
||||
{early
|
||||
? <p className="plan-note">سعر مستقبلي — غير قابل للشراء حالياً.</p>
|
||||
: <p className="plan-note">تلغي متى شئت، ويبقى حسابك المجاني ومحفوظاتك.</p>}
|
||||
<div className="plan-cta">
|
||||
{entitled ? (
|
||||
<a className="btn btn-primary" href="/account-settings">عضويتك فعّالة، أدر حسابك</a>
|
||||
) : (
|
||||
<SubscribeButton loggedIn={Boolean(user)} />
|
||||
<SubscribeButton loggedIn={Boolean(user)} comingSoon={early} />
|
||||
)}
|
||||
</div>
|
||||
<p className="fine">الدفع عبر Stripe. لا نحفظ بيانات بطاقتك.</p>
|
||||
<p className="fine">{early ? 'لن يتم إنشاء اشتراك أو تحصيل مبلغ تلقائياً.' : 'الدفع عبر Stripe. لا نحفظ بيانات بطاقتك.'}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -118,7 +137,22 @@ export default async function SubscriptionPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mem-faq" aria-labelledby="faq-title">
|
||||
<section className="mem-faq" id="faq" aria-labelledby="faq-title">
|
||||
{/* L-09: FAQPage structured data mirrors the visible FAQ. */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_ITEMS.map(item => ({
|
||||
'@type': 'Question',
|
||||
name: item.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: item.a },
|
||||
})),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<div className="wrap faq-grid">
|
||||
<div>
|
||||
<h2 id="faq-title">أسئلة قبل الاشتراك</h2>
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function SubscribeButton({ loggedIn }: { loggedIn: boolean }) {
|
||||
/* comingSoon comes from the server page (!billingEnabled()) so the disabled
|
||||
«قريباً» state + mailto render from first paint — no POST is attempted while
|
||||
checkout is disabled (L-01). A 503 from the API (e.g. mode flipped after
|
||||
hydration) lands in the same honest state. */
|
||||
export default function SubscribeButton({ loggedIn, comingSoon = false }: { loggedIn: boolean; comingSoon?: boolean }) {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [comingSoon, setComingSoon] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
async function subscribe() {
|
||||
setPending(true)
|
||||
@@ -17,13 +21,18 @@ export default function SubscribeButton({ loggedIn }: { loggedIn: boolean }) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Stripe pending mode (no keys configured) or failure
|
||||
setComingSoon(true)
|
||||
// 503 (billing disabled) or any failure → honest coming-soon state.
|
||||
setFailed(true)
|
||||
setPending(false)
|
||||
}
|
||||
|
||||
if (comingSoon) {
|
||||
return <p className="plan-soon" role="status">الدفع الإلكتروني متاح قريباً. راسلنا على <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> لتفعيل عضويتك يدوياً.</p>
|
||||
if (comingSoon || failed) {
|
||||
return (
|
||||
<div className="plan-soon-wrap">
|
||||
<button className="btn btn-primary" type="button" disabled>قريباً</button>
|
||||
<p className="plan-soon" role="status">الدفع الإلكتروني غير مفعّل خلال فترة الوصول المبكر. راسلنا على <a href="mailto:hello@anwarpy.ai" dir="ltr">hello@anwarpy.ai</a> لتفعيل عضويتك يدوياً.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { headers } from 'next/headers'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { TOPICS, topicsFor, isTopic } from '@/lib/topics'
|
||||
import { guidesLabel, minutesLabel, formatNumber, contentTypeLabel } from '@/lib/format'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
import Topnav from '@/components/topnav'
|
||||
import Footer from '@/components/footer'
|
||||
|
||||
type Args = { params: Promise<{ topic: string }> }
|
||||
|
||||
type HubPost = {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
dek: string
|
||||
access: 'free' | 'vip'
|
||||
category: string
|
||||
level: string
|
||||
readingMinutes: number
|
||||
views: number
|
||||
cover?: { url?: string | null } | number | null
|
||||
contentType?: string | null
|
||||
}
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
|
||||
export async function generateMetadata({ params }: Args): Promise<Metadata> {
|
||||
const { topic } = await params
|
||||
if (!isTopic(topic)) return { title: 'أنور بآي' }
|
||||
const meta = TOPICS.find(t => t.key === topic)
|
||||
return {
|
||||
title: `${meta?.label} — مسار`,
|
||||
description: `كل أدلة مسار ${meta?.label} في أنور بآي: ${meta?.hint}. أدلة عملية بالعربية بخطوات قابلة للتطبيق.`,
|
||||
alternates: { canonical: `/topics/${topic}` },
|
||||
}
|
||||
}
|
||||
|
||||
export default async function TopicPage({ params }: Args) {
|
||||
const { topic } = await params
|
||||
if (!isTopic(topic)) notFound()
|
||||
const meta = TOPICS.find(t => t.key === topic)!
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const [res, auth] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'posts',
|
||||
where: { status: { equals: 'published' } },
|
||||
depth: 1,
|
||||
limit: 200,
|
||||
sort: '-publishedAt',
|
||||
select: {
|
||||
title: true, slug: true, dek: true, access: true, category: true, level: true,
|
||||
readingMinutes: true, views: true, cover: true, contentType: true,
|
||||
},
|
||||
}),
|
||||
payload.auth({ headers: await headers() }),
|
||||
])
|
||||
const role = (auth.user?.role as string) || null
|
||||
// Topic membership is derived (category + level), same rule as the library/home.
|
||||
const posts = (res.docs as unknown as (HubPost & { likes?: number })[]).filter(post => topicsFor(post).includes(topic))
|
||||
const totalMinutes = posts.reduce((sum, post) => sum + (post.readingMinutes ?? 0), 0)
|
||||
|
||||
return (
|
||||
<div id="site" className="site-page v2 page-topic">
|
||||
{/* L-09: Breadcrumb structured data mirrors the visible trail. */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'الرئيسية', item: SITE_URL },
|
||||
{ '@type': 'ListItem', position: 2, name: meta.label, item: `${SITE_URL}/topics/${topic}` },
|
||||
],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Topnav viewer={auth.user ? { email: String(auth.user.email) } : null} />
|
||||
<main id="content">
|
||||
<section className="topic-hero">
|
||||
<div className="wrap narrow">
|
||||
<p className="crumbs" dir="rtl"><Link href="/">الرئيسية</Link> <span aria-hidden="true">/</span> <span>{meta.label}</span></p>
|
||||
<h1>{meta.label}</h1>
|
||||
<p className="topic-hint">{meta.hint} · {guidesLabel(posts.length)} · {minutesLabel(totalMinutes)} قراءة</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="topic-list" aria-label={`أدلة ${meta.label}`}>
|
||||
<div className="wrap narrow">
|
||||
{posts.length === 0 && (
|
||||
<p className="muted">لا أدلة منشورة في هذا المسار بعد. تابعنا للقادم قريباً.</p>
|
||||
)}
|
||||
<div className="lib-grid">
|
||||
{posts.map(post => {
|
||||
const coverUrl = typeof post.cover === 'object' ? post.cover?.url ?? null : null
|
||||
const locked = post.access === 'vip' && role !== 'vip' && role !== 'admin' && !freeEarlyAccess()
|
||||
return (
|
||||
<article className="post guide-card" key={post.id}>
|
||||
{coverUrl && <img className="guide-card-art" src={coverUrl} alt="" width={640} height={320} loading="lazy" />}
|
||||
<div className="guide-card-body">
|
||||
<p className="meta-row">
|
||||
<span>{post.category}</span>
|
||||
{contentTypeLabel(post.contentType) && <span className="badge">{contentTypeLabel(post.contentType)}</span>}
|
||||
<span>{minutesLabel(post.readingMinutes)}</span>
|
||||
<span>{post.level}</span>
|
||||
</p>
|
||||
<h2 className="guide-card-title"><Link href={`/posts/${post.slug}`}>{post.title}</Link></h2>
|
||||
<p className="guide-card-dek">{post.dek}</p>
|
||||
<div className="guide-card-foot">
|
||||
<span className={`badge ${post.access === 'vip' ? 'badge-vip' : 'badge-free'}`}>{post.access === 'free' ? 'مجاني' : 'للأعضاء'}</span>
|
||||
{post.views != null && <span className="engagement">{formatNumber(post.views)} قراءة</span>}
|
||||
{locked && <span className="badge badge-vip">يتطلب عضوية</span>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="topic-other">
|
||||
مسارات أخرى:{' '}
|
||||
{TOPICS.filter(t => t.key !== topic).map((t, i) => (
|
||||
<span key={t.key}>{i > 0 && ' · '}<Link href={`/topics/${t.key}`}>{t.label}</Link></span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,12 +19,14 @@ export default async function VerifyEmailPage({ searchParams }: Props) {
|
||||
const { invalid } = await searchParams
|
||||
let email: string | null = null
|
||||
let verified = false
|
||||
let userId: number | null = null
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (user) {
|
||||
email = user.email
|
||||
verified = !!user.emailVerified
|
||||
userId = Number(user.id)
|
||||
}
|
||||
} catch {
|
||||
/* no db / no session — page still renders */
|
||||
@@ -48,9 +50,10 @@ export default async function VerifyEmailPage({ searchParams }: Props) {
|
||||
<ol className="steps" aria-label="خطوات تأكيد البريد">
|
||||
<li><span className="step-num">01</span><span>افتح رسالة أنور بآي في صندوق الوارد.</span></li>
|
||||
<li><span className="step-num">02</span><span>اضغط رابط تأكيد البريد الإلكتروني داخل الرسالة.</span></li>
|
||||
<li><span className="step-num">03</span><span>عد هنا لإعداد حسابك ومسارك الأول.</span></li>
|
||||
<li><span className="step-num">03</span><span>افحص مجلد الرسائل غير المرغوبة (Spam).</span></li>
|
||||
<li><span className="step-num">04</span><span>عد هنا لإعداد حسابك ومسارك الأول.</span></li>
|
||||
</ol>
|
||||
<VerifyActions verified={verified} />
|
||||
<VerifyActions verified={verified} email={email} userId={userId} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,12 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/* Resend button + status — real /api/auth/resend-verification endpoint.
|
||||
Without SMTP configured the API answers 503 with a clear message. */
|
||||
export default function VerifyActions({ verified }: { verified: boolean }) {
|
||||
Without SMTP configured the API answers 503 with a clear message.
|
||||
«البريد غير صحيح؟» — collapsed change-email affordance: PATCH /api/users/:id
|
||||
(self-update allowed in Users.ts access) then resend; 400 → duplicate-email
|
||||
copy. Hidden without a session (no id → nothing to patch). */
|
||||
export default function VerifyActions({ verified, email, userId }: { verified: boolean; email: string | null; userId: number | null }) {
|
||||
const [status, setStatus] = useState('')
|
||||
const [newEmail, setNewEmail] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (email) {
|
||||
setNewEmail(email)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const saved = sessionStorage.getItem('anwarpi:signup-email')
|
||||
if (saved) setNewEmail(saved)
|
||||
} catch { /* private mode */ }
|
||||
}, [email])
|
||||
|
||||
async function changeEmail() {
|
||||
if (busy || !userId) return
|
||||
setBusy(true)
|
||||
setStatus('جارٍ التحديث…')
|
||||
try {
|
||||
const patchRes = await fetch(`/api/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: newEmail.trim() }),
|
||||
})
|
||||
if (!patchRes.ok) {
|
||||
setStatus(patchRes.status === 400 ? 'هذا البريد مسجَّل لدينا.' : 'تعذّر تحديث البريد، حاول مرة أخرى.')
|
||||
return
|
||||
}
|
||||
const resendRes = await fetch('/api/auth/resend-verification', { method: 'POST' })
|
||||
const data = await resendRes.json().catch(() => null) as { error?: string; already?: boolean } | null
|
||||
setStatus(resendRes.ok || data?.already ? 'أرسلنا رسالة تأكيد جديدة إلى بريدك المحدَّث.' : data?.error || 'تم تحديث البريد لكن تعذّر الإرسال الآن.')
|
||||
} catch {
|
||||
setStatus('تعذّر الاتصال، حاول مرة أخرى.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -19,7 +59,7 @@ export default function VerifyActions({ verified }: { verified: boolean }) {
|
||||
button.disabled = true
|
||||
setStatus('جارٍ الإرسال…')
|
||||
const res = await fetch('/api/auth/resend-verification', { method: 'POST' })
|
||||
const data = await res.json().catch(() => null)
|
||||
const data = await res.json().catch(() => null) as { error?: string; already?: boolean } | null
|
||||
if (res.ok || data?.already) {
|
||||
setStatus('أرسلنا الرسالة من جديد — افحص صندوق الوارد.')
|
||||
} else {
|
||||
@@ -33,6 +73,17 @@ export default function VerifyActions({ verified }: { verified: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
<p className="status" id="resendStatus" role="status" aria-live="polite">{status}</p>
|
||||
{userId && !verified && (
|
||||
<details className="change-email" data-od-id="change-email">
|
||||
<summary>البريد غير صحيح؟</summary>
|
||||
<div className="change-email-row">
|
||||
<input className="input" type="email" inputMode="email" dir="ltr" value={newEmail} onChange={e => setNewEmail(e.target.value)} aria-label="البريد الإلكتروني الصحيح" />
|
||||
<button className="btn btn-secondary" type="button" onClick={changeEmail} disabled={busy || newEmail.trim() === ''}>
|
||||
{busy ? 'جارٍ التحديث…' : 'حدّث البريد وأعد الإرسال'}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { Media } from '@payload-types'
|
||||
import configPromise from '@payload-config'
|
||||
import { resolveCoverImage } from '@/lib/agent-media'
|
||||
import { resolveAttachments, deleteFileDocs } from '@/lib/agent-files'
|
||||
import { scanAgentPayload } from '@/lib/secret-scan'
|
||||
import { lexicalToText } from '@/lib/lexical'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -20,9 +22,11 @@ function validKey(req: NextRequest) {
|
||||
return req.headers.get('x-agent-key') === key
|
||||
}
|
||||
|
||||
/** Fields the agent is allowed to write; status is NEVER included. */
|
||||
/** Fields the agent is allowed to write; status is NEVER included.
|
||||
* `access`/`readingMinutes` are admin-only (tier changes and reading time
|
||||
* are admin/agent-policy decisions — the hook computes honest minutes). */
|
||||
function pickFields(body: Body): Body {
|
||||
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const
|
||||
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'cover'] as const
|
||||
const out: Body = {}
|
||||
for (const field of allowed) {
|
||||
if (field in body) out[field] = body[field]
|
||||
@@ -58,6 +62,21 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'slug is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// L-10 dedup first: a retried ingest with the same sourceId surfaces the
|
||||
// existing post (200) instead of a misleading slug 409.
|
||||
if (typeof body.sourceId === 'string' && body.sourceId.trim() !== '') {
|
||||
const dupe = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { sourceId: { equals: body.sourceId } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const dup = dupe.docs[0] as { id: number; slug: string; status: string } | undefined
|
||||
if (dup) {
|
||||
return NextResponse.json({ id: dup.id, slug: dup.slug, status: dup.status, deduplicated: true }, { status: 200 })
|
||||
}
|
||||
}
|
||||
|
||||
// Slug conflict → 409
|
||||
const existing = await payload.find({
|
||||
collection: 'posts',
|
||||
@@ -69,6 +88,20 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const data = pickFields(body)
|
||||
if (typeof body.sourceId === 'string' && body.sourceId.trim() !== '') data.sourceId = body.sourceId
|
||||
|
||||
// L-05: pattern names only — matched values are never stored or echoed.
|
||||
// A string content (wrong shape) is scanned raw too — lexicalToText of a
|
||||
// string returns '' and would silently skip the scan.
|
||||
const secretPatterns = scanAgentPayload([
|
||||
data.title as string,
|
||||
data.dek as string,
|
||||
data.teaser as string,
|
||||
typeof data.content === 'string' ? data.content : lexicalToText(data.content),
|
||||
])
|
||||
if (secretPatterns.length > 0) {
|
||||
return NextResponse.json({ error: 'content rejected: potential secrets', patterns: secretPatterns }, { status: 422 })
|
||||
}
|
||||
|
||||
// Per-post work files (inline base64) → stored as gated `files` documents.
|
||||
// Like coverImage, `attachments` is an input transform, not a raw field.
|
||||
@@ -99,7 +132,14 @@ export async function POST(req: NextRequest) {
|
||||
// directly (unpublished content requires a deliberate admin action otherwise).
|
||||
const publish = body.publish === true
|
||||
if (publish && !data.publishedAt) data.publishedAt = new Date().toISOString()
|
||||
if (!data.content || typeof data.content !== 'object') {
|
||||
if (typeof data.content === 'string') {
|
||||
// Wrong shape — never store it and never silently drop the agent's text.
|
||||
return NextResponse.json(
|
||||
{ error: 'content must be Lexical JSON ({ root: { children: [...] } }), not a markdown/plain string' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (!data.content) {
|
||||
// minimal valid Lexical doc (empty root is rejected by validation); agent PUTs full content later
|
||||
data.content = {
|
||||
root: {
|
||||
@@ -159,6 +199,16 @@ export async function PUT(req: NextRequest) {
|
||||
const data = pickFields(body)
|
||||
delete (data as Body).status // agent can never publish/unpublish
|
||||
|
||||
const secretPatterns = scanAgentPayload([
|
||||
data.title as string,
|
||||
data.dek as string,
|
||||
data.teaser as string,
|
||||
typeof data.content === 'string' ? data.content : lexicalToText(data.content),
|
||||
])
|
||||
if (secretPatterns.length > 0) {
|
||||
return NextResponse.json({ error: 'content rejected: potential secrets', patterns: secretPatterns }, { status: 422 })
|
||||
}
|
||||
|
||||
const doc = await payload.update({
|
||||
collection: 'posts',
|
||||
id: post.id,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { FILES_DIR } from '@/collections/Files'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -26,7 +27,10 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
|
||||
}
|
||||
if (!doc?.filename) return NextResponse.json({ error: 'الملف غير موجود.' }, { status: 404 })
|
||||
|
||||
if (doc.access === 'vip') {
|
||||
// L-02: all files open during free early access (owner curates file
|
||||
// access in the admin; ALL vip files download while billing is off).
|
||||
// Viewer checks apply only when billing is enabled.
|
||||
if (doc.access === 'vip' && !freeEarlyAccess()) {
|
||||
const viewer = await getViewer()
|
||||
if (!viewer) return NextResponse.json({ error: 'سجّل الدخول لتنزيل الملفات.' }, { status: 401 })
|
||||
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { sendMail } from '@/lib/mail'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
/* POST /api/notify — L-01 launch-notification list.
|
||||
Idempotent: an existing email returns 200 { ok: true, alreadySubscribed: true }
|
||||
(no duplicate row, no re-send). A fresh email is stored and a confirmation
|
||||
is sent; if the mail transport is not wired the response is an honest 503
|
||||
(the row is kept — no fake success). */
|
||||
export async function POST(req: Request) {
|
||||
let body: { email?: string } | null = null
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'صيغة الطلب غير صحيحة.' }, { status: 400 })
|
||||
}
|
||||
const email = (body?.email ?? '').trim().toLowerCase()
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
return NextResponse.json({ error: 'أدخل بريداً إلكترونياً صحيحاً.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const { docs } = await payload.find({
|
||||
collection: 'launch-notifications',
|
||||
where: { email: { equals: email } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
if (docs.length > 0) {
|
||||
return NextResponse.json({ ok: true, alreadySubscribed: true })
|
||||
}
|
||||
|
||||
try {
|
||||
await payload.create({
|
||||
collection: 'launch-notifications',
|
||||
data: { email, source: 'subscription' },
|
||||
})
|
||||
} catch (err) {
|
||||
// Lost a unique-constraint race against a concurrent signup → idempotent OK.
|
||||
const message = err instanceof Error ? err.message : ''
|
||||
if (/duplicate key|unique constraint/i.test(message)) {
|
||||
return NextResponse.json({ ok: true, alreadySubscribed: true })
|
||||
}
|
||||
console.error('launch notification create failed:', err)
|
||||
return NextResponse.json({ error: 'تعذر حفظ البريد، حاول مرة أخرى.' }, { status: 500 })
|
||||
}
|
||||
|
||||
const { sent } = await sendMail(
|
||||
email,
|
||||
'سنعلمك عند تفعيل العضوية — أنور بي آي',
|
||||
'مرحباً بك في أنور بي آي.\n\nسجّلنا بريدك في قائمة الإشعار، وسنراسلك قبل تفعيل العضوية بأيام.\n\nخلال الوصول المبكر كل الأدلة المنشورة متاحة مجاناً، ولن يُنشأ لك اشتراك أو يُحصَّل أي مبلغ.',
|
||||
)
|
||||
if (!sent) {
|
||||
return NextResponse.json({ error: 'خدمة البريد غير مربوطة بعد.' }, { status: 503 })
|
||||
}
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { REST_DELETE, REST_GET, REST_PATCH, REST_PUT } from '@payloadcms/next/routes'
|
||||
import { getViewer } from '@/lib/viewer'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -35,7 +36,10 @@ export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
})
|
||||
const post = docs[0]
|
||||
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (post.access === 'vip') {
|
||||
// L-02: in free early access, published VIP posts serve the full body
|
||||
// (mirrors the page + field-ACL policy); the tier gate applies only when
|
||||
// billing is enabled.
|
||||
if (post.access === 'vip' && !freeEarlyAccess()) {
|
||||
const viewer = await getViewer()
|
||||
if (!viewer) return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
if (viewer.role !== 'vip' && viewer.role !== 'admin') {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Payload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import Stripe from 'stripe'
|
||||
import configPromise from '@payload-config'
|
||||
import { billingEnabled } from '@/lib/launch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -12,15 +13,17 @@ export async function POST() {
|
||||
const priceId = process.env.STRIPE_PRICE_ID
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
|
||||
|
||||
// Stripe pending mode: no keys yet → subscription is «قريباً» (coming soon).
|
||||
// No VIP is granted without a Stripe-verified session; adding
|
||||
// STRIPE_SECRET_KEY + STRIPE_PRICE_ID switches to real Checkout with the
|
||||
// same success/cancel pages.
|
||||
// L-01: checkout is disabled server-side during free early access —
|
||||
// 503, and never a Checkout session in this mode. Paid transition =
|
||||
// LAUNCH_MODE=paid + Stripe keys; this route re-enables with no code change.
|
||||
if (!billingEnabled()) {
|
||||
return NextResponse.json({ error: 'الدفع غير مفعّل خلال فترة الوصول المبكر.' }, { status: 503 })
|
||||
}
|
||||
|
||||
// Config-error guard for paid mode (TS narrowing; billingEnabled() already
|
||||
// guarantees the secret) — never create a session with a missing price.
|
||||
if (!secret || !priceId) {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
return NextResponse.json({ comingSoon: true })
|
||||
return NextResponse.json({ error: 'الدفع غير مكتمل الإعداد حالياً.' }, { status: 503 })
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
@@ -207,6 +207,8 @@ body { background: var(--ink); color: var(--mist); }
|
||||
#site .membership-offer { border: 1px solid var(--seam); border-top: 2px solid var(--champagne); border-radius: var(--r-md); padding: 28px; background: var(--basalt); display: grid; gap: 18px; }
|
||||
#site .membership-offer .price { font: 600 40px/1 var(--font-display); color: var(--mist); display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
#site .membership-offer .price span { font: 400 15px/1.5 var(--font-body); color: var(--stone); }
|
||||
#site .membership-offer .price-early { color: var(--champagne); }
|
||||
#site .membership-offer .early-line { color: var(--stone); font-size: 15.5px; margin: -8px 0 0; }
|
||||
#site .membership-offer ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
||||
#site .membership-offer li { display: flex; gap: 12px; align-items: baseline; color: var(--mist); font-size: 15.5px; }
|
||||
#site .membership-offer li::before { content: ""; flex: none; width: 9px; height: 8px; background: var(--champagne); clip-path: polygon(50% 0, 100% 100%, 0 100%); transform: translateY(-1px); }
|
||||
@@ -277,7 +279,22 @@ body { background: var(--ink); color: var(--mist); }
|
||||
#site .art-body li + li { margin-top: .4em; }
|
||||
#site .art-body li::marker { color: var(--champagne); }
|
||||
#site .art-body blockquote { margin-inline: 0; padding: 4px 20px; border-inline-start: 2px solid var(--champagne); color: var(--mist); font-size: 19px; }
|
||||
#site .topic-hero { padding-block: clamp(40px, 6vw, 72px) 8px; }
|
||||
#site .topic-hero h1 { font-size: clamp(28px, 3vw, 40px); margin: 0 0 6px; }
|
||||
#site .topic-hero .crumbs { display: flex; gap: 8px; color: var(--stone); font-size: 14px; margin: 0 0 18px; }
|
||||
#site .topic-hero .crumbs a { color: var(--champagne); }
|
||||
#site .topic-hint { color: var(--mist); margin: 0; }
|
||||
#site .topic-list { padding-block: 32px 72px; }
|
||||
#site .topic-other { margin-top: 40px; color: var(--stone); font-size: 15px; }
|
||||
#site .topic-other a { color: var(--champagne); }
|
||||
#site .topic-strip { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 22px; }
|
||||
#site .topic-strip a { display: inline-block; padding: 6px 16px; border: 1px solid var(--seam); border-radius: 999px; color: var(--mist); font-size: 14.5px; }
|
||||
#site .topic-strip a:hover { border-color: var(--champagne); color: var(--champagne); }
|
||||
#site .art-body pre { direction: ltr; text-align: left; background: var(--ink-2); border: 1px solid var(--seam); border-radius: var(--r-md); padding: 20px 22px; overflow-x: auto; font: 14.5px/1.75 ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
|
||||
#site .art-body .post-table { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
#site .art-body .post-table table { width: 100%; border-collapse: collapse; font-size: 16px; line-height: 1.8; }
|
||||
#site .art-body .post-table th, #site .art-body .post-table td { border: 1px solid var(--seam); padding: 10px 14px; text-align: start; vertical-align: top; }
|
||||
#site .art-body .post-table th { background: var(--ink-2); font-weight: 600; color: var(--mist); }
|
||||
#site .art-body pre code { background: none; padding: 0; white-space: pre; }
|
||||
#site .art-body hr { border: 0; height: 1px; background: var(--seam); margin-block: 2.4em; }
|
||||
#site .art-body .intro { font-size: 21px; line-height: 1.9; color: var(--mist); }
|
||||
@@ -285,6 +302,11 @@ body { background: var(--ink); color: var(--mist); }
|
||||
#site .paywall h2 { font-size: clamp(24px, 2.4vw, 30px); margin: 0 0 12px; }
|
||||
#site .paywall p { color: var(--stone); font-size: 16.5px; }
|
||||
#site .paywall-actions { display: flex; flex-wrap: wrap; gap: 12px; margin: 24px 0 14px; }
|
||||
#site .paywall-preview ul { list-style: none; margin: 0 0 6px; padding: 0; display: grid; gap: 8px; }
|
||||
#site .paywall-preview li { display: flex; gap: 10px; align-items: baseline; color: var(--mist); font-size: 15.5px; }
|
||||
#site .paywall-preview li svg { flex: none; width: 14px; height: 14px; transform: translateY(2px); }
|
||||
#site .early-notice { margin: 0 0 28px; padding: 14px 18px; border: 1px solid var(--seam); border-inline-start: 2px solid var(--champagne); border-radius: var(--r-md); background: var(--basalt); }
|
||||
#site .early-notice p { color: var(--stone); font-size: 16.5px; margin: 0; }
|
||||
#site .art-aside { position: sticky; top: 104px; display: grid; gap: 28px; }
|
||||
#site .aside-title { font: 600 15px/1.6 var(--font-body); color: var(--mist); margin-bottom: 10px; }
|
||||
#site .toc ol { list-style: none; margin: 0; padding: 0; border-inline-start: 1px solid var(--seam); }
|
||||
@@ -324,6 +346,13 @@ body { background: var(--ink); color: var(--mist); }
|
||||
MEMBERSHIP
|
||||
================================================================== */
|
||||
#site.v2-membership .mem-hero { padding-block: clamp(56px, 8vw, 104px); border-bottom: 1px solid var(--seam); }
|
||||
#site.v2-membership .early-access { padding-top: clamp(32px, 5vw, 56px); }
|
||||
#site.v2-membership .early-access .wrap > p { color: var(--stone); max-width: 70ch; }
|
||||
#site.v2-membership .early-access h2 { font-size: clamp(22px, 2.4vw, 30px); margin-bottom: 10px; }
|
||||
#site .notify-form { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 18px; align-items: center; }
|
||||
#site .notify-form input { min-height: 48px; padding: 0 14px; border: 1px solid var(--seam); border-radius: var(--r-sm, 10px); background: var(--bg); color: var(--fg); font: inherit; min-width: min(320px, 100%); }
|
||||
#site .notify-form .notify-error { color: var(--error, #b4443c); width: 100%; margin: 0; }
|
||||
#site .notify-done { color: var(--success, var(--champagne)); font-weight: 600; margin-top: 18px; }
|
||||
#site.v2-membership .mem-hero-grid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 1fr); gap: clamp(32px, 6vw, 88px); align-items: start; }
|
||||
#site.v2-membership .mem-hero h1 { font-size: clamp(36px, 4.6vw, 60px); max-width: 15ch; }
|
||||
#site.v2-membership .mem-hero .lede { margin-top: 22px; }
|
||||
@@ -341,6 +370,7 @@ body { background: var(--ink); color: var(--mist); }
|
||||
#site .plan-cta .btn { width: 100%; }
|
||||
#site .plan-soon { padding: 14px 16px; border: 1px dashed var(--seam-2); border-radius: var(--r-sm); color: var(--stone); font-size: 14.5px; }
|
||||
#site .plan-soon a { color: var(--mist); text-decoration: underline; text-underline-offset: 4px; }
|
||||
#site .plan-soon-wrap { display: grid; gap: 10px; justify-items: start; }
|
||||
#site.v2-membership .mem-samples { padding-block: clamp(64px, 8vw, 104px); background: var(--ink-2); border-bottom: 1px solid var(--seam); }
|
||||
#site .sample-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px 24px; }
|
||||
#site .sample { display: grid; gap: 8px; align-content: start; height: 100%; }
|
||||
@@ -605,6 +635,7 @@ body, #site { background: var(--ink); color: var(--mist); }
|
||||
#site.v2-article .art-head { background: var(--ink-2); }
|
||||
#site .art-body pre { background: #0f172a; color: #e2e8f0; border-color: #0f172a; }
|
||||
#site .paywall { background: #f2faf7; border-color: #cfe9e1; border-top-color: var(--champagne); }
|
||||
#site .early-notice { background: #f2faf7; border-color: #cfe9e1; border-inline-start-color: var(--champagne); }
|
||||
#site .comment, #site .comment-gate { background: #fff; }
|
||||
#site .art-related { background: var(--ink-2); }
|
||||
#site :is(.related-item, .sample) { background: #fff; }
|
||||
|
||||
@@ -19,7 +19,7 @@ export const viewport: Viewport = { colorScheme: 'only light', themeColor: '#fff
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'),
|
||||
title: { default: 'أنور بآي', template: '%s — أنور بآي' },
|
||||
title: { default: 'أنور بآي — أدلة الذكاء الاصطناعي العملي بالعربية', template: '%s — أنور بآي' },
|
||||
description: 'دليلك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.',
|
||||
alternates: { canonical: './' },
|
||||
openGraph: {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { SITE_INTRO, SITE_TITLE, fetchIndexDocs, renderIndexSections, type IndexDoc } from '@/lib/llms'
|
||||
import { freeEarlyAccess } from '@/lib/launch'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { lexicalToPlain } from '@/lib/lexical'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const early = freeEarlyAccess()
|
||||
// Public read path: the content field's read ACL already decides body
|
||||
// availability — with early access every published body passes; with billing
|
||||
// on, VIP bodies come back undefined and are listed members-only.
|
||||
const docs = await fetchIndexDocs(true) as (IndexDoc & { content?: unknown })[]
|
||||
|
||||
const parts: string[] = [
|
||||
`# ${SITE_TITLE}`,
|
||||
'',
|
||||
`> ${SITE_INTRO}`,
|
||||
'',
|
||||
renderIndexSections(docs, !early),
|
||||
'---',
|
||||
'',
|
||||
'# الأدلة الكاملة',
|
||||
'',
|
||||
]
|
||||
|
||||
for (const doc of docs) {
|
||||
const vipOnly = !early && doc.access === 'vip'
|
||||
const suffix = vipOnly ? ' (دليل للأعضاء)' : ''
|
||||
parts.push(`## ${doc.title}${suffix}`, '')
|
||||
parts.push(`URL: /posts/${doc.slug}`, '')
|
||||
if (doc.dek) parts.push(doc.dek, '')
|
||||
if (doc.updatedAt && doc.publishedAt && doc.updatedAt !== doc.publishedAt) {
|
||||
parts.push(`حُدّث: ${formatDate(doc.updatedAt)}`, '')
|
||||
}
|
||||
if (vipOnly) {
|
||||
parts.push('هذا الدليل متاح للأعضاء فقط. العضوية: /subscription', '')
|
||||
} else if (typeof doc.content === 'object' && doc.content !== null) {
|
||||
parts.push(lexicalToPlain(doc.content), '')
|
||||
}
|
||||
parts.push('---', '')
|
||||
}
|
||||
|
||||
return new Response(parts.join('\n'), { headers: { 'content-type': 'text/plain; charset=utf-8' } })
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SITE_INTRO, SITE_TITLE, fetchIndexDocs, renderIndexSections } from '@/lib/llms'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const docs = await fetchIndexDocs()
|
||||
const body = [
|
||||
`# ${SITE_TITLE}`,
|
||||
'',
|
||||
`> ${SITE_INTRO}`,
|
||||
'',
|
||||
`النسخة الكاملة: /llms-full.txt`,
|
||||
'',
|
||||
renderIndexSections(docs, false),
|
||||
].join('\n')
|
||||
return new Response(body, { headers: { 'content-type': 'text/plain; charset=utf-8' } })
|
||||
}
|
||||
+25
-2
@@ -1,12 +1,35 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
|
||||
/* Owner decision: allow all named search/AI crawlers explicitly. The `*`
|
||||
group keeps its `/api` disallow; the named groups opt back into the
|
||||
public content APIs and the llms.txt feeds. */
|
||||
const AI_BOTS = [
|
||||
'GPTBot',
|
||||
'OAI-SearchBot',
|
||||
'ChatGPT-User',
|
||||
'ClaudeBot',
|
||||
'Claude-User',
|
||||
'Claude-SearchBot',
|
||||
'PerplexityBot',
|
||||
'Google-Extended',
|
||||
'Applebot',
|
||||
'Bingbot',
|
||||
]
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [{
|
||||
rules: [
|
||||
...AI_BOTS.map(userAgent => ({
|
||||
userAgent,
|
||||
allow: ['/', '/api/posts', '/llms.txt', '/llms-full.txt'],
|
||||
})),
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin', '/api', '/account-settings', '/onboarding', '/vip-onboarding', '/verify-email', '/payment-success', '/payment-cancelled', '/forgot-password'],
|
||||
}],
|
||||
},
|
||||
],
|
||||
sitemap: `${base}/sitemap.xml`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { TOPICS } from '@/lib/topics'
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://anwarpi.ai'
|
||||
|
||||
@@ -14,7 +15,9 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
})
|
||||
const staticPaths = ['', '/articles', '/subscription', '/terms', '/privacy', '/about', '/login', '/signup']
|
||||
const staticPaths = ['', '/articles', '/subscription', '/terms', '/privacy', '/about',
|
||||
// Topic hubs are indexable; /login and /signup are noindex (step 38).
|
||||
...TOPICS.map(topic => `/topics/${topic.key}`)]
|
||||
return [
|
||||
...staticPaths.map(p => ({ url: `${base}${p}` })),
|
||||
...posts.docs.map(p => ({ url: `${base}/posts/${p.slug}`, lastModified: p.updatedAt ? new Date(String(p.updatedAt)) : undefined })),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||
|
||||
const isAdmin = (args: AccessArgs) =>
|
||||
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
|
||||
|
||||
/* L-01 early-access notification list — real, minimal. The /api/notify route
|
||||
is the primary writer (duplicate check + confirmation email); direct REST
|
||||
create stays open (`create: () => true`) so the list never silently drops
|
||||
a signup, but reads/updates/deletes are admin-only. */
|
||||
const LaunchNotifications: CollectionConfig = {
|
||||
slug: 'launch-notifications',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'source', 'createdAt'],
|
||||
},
|
||||
access: {
|
||||
create: () => true,
|
||||
read: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
admin: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'email', type: 'text', required: true, unique: true, index: true },
|
||||
{
|
||||
name: 'source',
|
||||
type: 'select',
|
||||
defaultValue: 'subscription',
|
||||
options: [{ label: 'صفحة العضوية', value: 'subscription' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default LaunchNotifications
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||
import { freeEarlyAccess, READING_WORDS_PER_MINUTE } from '@/lib/launch'
|
||||
import { countLexicalWords } from '@/lib/lexical'
|
||||
|
||||
const isAdmin = (args: AccessArgs) =>
|
||||
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
|
||||
@@ -7,6 +9,19 @@ export const CATEGORIES = ['وكلاء', 'نماذج', 'استرجاع', 'سلا
|
||||
|
||||
const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
hooks: {
|
||||
// L-07: honest reading estimates. Recomputed from the body on every
|
||||
// write — agent/admin-supplied `readingMinutes` is always overridden.
|
||||
beforeChange: [
|
||||
({ data, originalDoc }) => {
|
||||
const words =
|
||||
data.content !== undefined
|
||||
? countLexicalWords(data.content)
|
||||
: countLexicalWords(originalDoc?.content)
|
||||
return { ...data, readingMinutes: Math.max(1, Math.round(words / READING_WORDS_PER_MINUTE)) }
|
||||
},
|
||||
],
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'category', 'access', 'status', 'publishedAt'],
|
||||
@@ -61,8 +76,13 @@ const Posts: CollectionConfig = {
|
||||
access: {
|
||||
// Never serve VIP body to non-VIP clients — defense in depth
|
||||
// (the paywall page also never queries it for them).
|
||||
read: ((args: { doc?: { access?: string }; req: { user?: { role?: string } | null } }) => {
|
||||
read: ((args: { doc?: { access?: string; status?: string }; req: { user?: { role?: string } | null } }) => {
|
||||
if (!args.doc) return true
|
||||
// L-02: during free early access, published posts serve their body
|
||||
// to everyone — Payload REST must not contradict the page. The
|
||||
// field-permission `doc` arg is the full document incl. `status`
|
||||
// (verified: payload/dist/fields/hooks/afterRead/promise.js:226).
|
||||
if (freeEarlyAccess() && args.doc.status === 'published') return true
|
||||
if (args.doc.access !== 'vip') return true
|
||||
return args.req.user?.role === 'vip' || args.req.user?.role === 'admin'
|
||||
}) as never, // field-permission signature is structurally compatible; TS can't infer it here
|
||||
@@ -95,11 +115,41 @@ const Posts: CollectionConfig = {
|
||||
{ label: 'متقدم', value: 'متقدم' },
|
||||
],
|
||||
},
|
||||
{ name: 'views', type: 'number', defaultValue: 0, admin: { readOnly: true, position: 'sidebar' } },
|
||||
{
|
||||
name: 'contentType',
|
||||
type: 'select',
|
||||
label: 'نوع المحتوى',
|
||||
options: [
|
||||
{ label: 'ملاحظة تقنية', value: 'note' },
|
||||
{ label: 'دليل عملي', value: 'tutorial' },
|
||||
{ label: 'تجربة موثقة', value: 'case_study' },
|
||||
{ label: 'مقارنة', value: 'comparison' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// L-09: real author display — never invent values; empty = no byline.
|
||||
name: 'authorName',
|
||||
type: 'text',
|
||||
label: 'اسم الكاتب',
|
||||
},
|
||||
{
|
||||
name: 'views',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
admin: { readOnly: true, position: 'sidebar' },
|
||||
},
|
||||
{ name: 'likes', type: 'number', defaultValue: 0, admin: { readOnly: true, position: 'sidebar' } },
|
||||
{ name: 'cover', type: 'upload', relationTo: 'media', required: true },
|
||||
{ name: 'readingMinutes', type: 'number', required: true, defaultValue: 5 },
|
||||
{ name: 'publishedAt', type: 'date', admin: { position: 'sidebar' } },
|
||||
{
|
||||
// Private traceability + agent-pipeline dedup key (L-10). Never exposed:
|
||||
// pickFields strips it on ingest and public queries never select it.
|
||||
name: 'sourceId',
|
||||
type: 'text',
|
||||
index: true,
|
||||
admin: { description: 'معرّف المصدر (خاص — للتتبّع ومنع التكرار)' },
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pbkdf2 } from 'crypto'
|
||||
import { pbkdf2, randomBytes } from 'crypto'
|
||||
import { promisify } from 'util'
|
||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||
import { sendVerificationEmail } from '@/lib/mail'
|
||||
|
||||
const isAdmin = (args: AccessArgs) =>
|
||||
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
|
||||
@@ -88,6 +89,31 @@ const Users: CollectionConfig = {
|
||||
return data
|
||||
},
|
||||
],
|
||||
// L-06 signup trust: the verify-email page claims «أرسلنا رابط التأكيد» —
|
||||
// make that true at signup instead of only after clicking resend. Mirror
|
||||
// api/auth/resend-verification exactly: fresh token → internal update
|
||||
// (systemActivation bypasses the beforeChange token strip) → email.
|
||||
// Mail env absent → sendVerificationEmail returns { sent: false } and the
|
||||
// hook skips silently; the resend route keeps the honest-503 path.
|
||||
afterChange: [
|
||||
async ({ doc, operation, req }) => {
|
||||
if (operation !== 'create' || !doc?.email) return
|
||||
try {
|
||||
const token = randomBytes(32).toString('hex')
|
||||
await req.payload.update({
|
||||
collection: 'users',
|
||||
id: doc.id,
|
||||
data: { verificationToken: token },
|
||||
overrideAccess: true,
|
||||
context: { systemActivation: true },
|
||||
req,
|
||||
})
|
||||
await sendVerificationEmail(doc.email, token)
|
||||
} catch (err) {
|
||||
console.error('signup verification email failed:', err)
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -27,3 +27,14 @@ export function minutesLabel(n: number | null | undefined): string {
|
||||
if (value === 2) return 'دقيقتان'
|
||||
return `${formatNumber(value)} ${value % 100 >= 3 && value % 100 <= 10 ? 'دقائق' : 'دقيقة'}`
|
||||
}
|
||||
|
||||
/* Content-type badge labels (L-09). Null for unset/unknown — no byline invented. */
|
||||
export function contentTypeLabel(value: string | null | undefined): string | null {
|
||||
switch (value) {
|
||||
case 'note': return 'ملاحظة تقنية'
|
||||
case 'tutorial': return 'دليل عملي'
|
||||
case 'case_study': return 'تجربة موثقة'
|
||||
case 'comparison': return 'مقارنة'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/* Single source of launch policy (L-01/L-02).
|
||||
|
||||
Free early access is a server-side policy, never a data rewrite: the
|
||||
intended VIP tier stays in the DB (`access: 'vip'`) and only becomes
|
||||
enforced again when billing turns on. Rollback / paid transition =
|
||||
set LAUNCH_MODE=paid + the Stripe env vars and redeploy. No timers,
|
||||
no date logic — idempotent by construction. */
|
||||
export const billingEnabled = (): boolean =>
|
||||
process.env.LAUNCH_MODE === 'paid' &&
|
||||
Boolean(process.env.STRIPE_SECRET_KEY) &&
|
||||
Boolean(process.env.STRIPE_WEBHOOK_SECRET)
|
||||
|
||||
export const freeEarlyAccess = (): boolean => !billingEnabled()
|
||||
|
||||
/* Configurable Arabic reading rate (words/minute); revisit after reader
|
||||
feedback. Used by the Posts beforeChange hook and the backfill script. */
|
||||
export const READING_WORDS_PER_MINUTE = 160
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Helpers over serialized Lexical richText JSON (Payload's editor state).
|
||||
* A serialized tree is `{ root: { children: [...] } }`; text lives on
|
||||
* `{ text }` nodes, nesting happens via `{ children }`.
|
||||
*/
|
||||
|
||||
export type Node = {
|
||||
type?: string
|
||||
text?: string
|
||||
children?: Node[]
|
||||
tag?: string
|
||||
url?: string
|
||||
listType?: string
|
||||
code?: string
|
||||
headerState?: number
|
||||
}
|
||||
|
||||
/** Root `children` of a serialized tree, narrowed at runtime. */
|
||||
function rootChildren(data: unknown): Node[] | null {
|
||||
if (data === null || typeof data !== 'object' || !('root' in data)) return null
|
||||
const root: unknown = data.root // unknown via `in` narrowing — no shape assumed
|
||||
if (root === null || typeof root !== 'object' || !('children' in root)) return null
|
||||
const children: unknown = root.children
|
||||
if (!Array.isArray(children)) return null
|
||||
// Boundary: the array is the editor's serialized node list; every access
|
||||
// below goes through Node's all-optional contract, so nothing unchecked.
|
||||
const nodes: Node[] = children
|
||||
return nodes
|
||||
}
|
||||
|
||||
/** Concatenated text of every text node (secret scanning, llms extraction). */
|
||||
export function lexicalToText(content: unknown): string {
|
||||
let out = ''
|
||||
const walk = (node: unknown): void => {
|
||||
if (node === null || typeof node !== 'object') return
|
||||
if ('text' in node && typeof node.text === 'string') out += node.text + '\n'
|
||||
if ('children' in node && Array.isArray(node.children)) {
|
||||
for (const child of node.children) walk(child)
|
||||
}
|
||||
}
|
||||
// Serialized trees wrap nodes in `root` — walk it, not the wrapper.
|
||||
if (content !== null && typeof content === 'object' && 'root' in content) {
|
||||
walk((content as { root: unknown }).root)
|
||||
} else {
|
||||
walk(content)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Count whitespace-delimited words across all text nodes of a Lexical tree. */
|
||||
export function countLexicalWords(content: unknown): number {
|
||||
let words = 0
|
||||
const walk = (node: unknown): void => {
|
||||
if (node === null || typeof node !== 'object') return
|
||||
if ('text' in node && typeof node.text === 'string') {
|
||||
words += node.text.trim().split(/\s+/).filter(Boolean).length
|
||||
}
|
||||
if ('children' in node && Array.isArray(node.children)) {
|
||||
for (const child of node.children) walk(child)
|
||||
}
|
||||
}
|
||||
// Same root unwrap as lexicalToText.
|
||||
if (content !== null && typeof content === 'object' && 'root' in content) {
|
||||
walk((content as { root: unknown }).root)
|
||||
} else {
|
||||
walk(content)
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
export function innerText(nodes?: Node[]): string {
|
||||
return (nodes || []).map(n => (n.type === 'text' ? n.text || '' : innerText(n.children))).join('')
|
||||
}
|
||||
|
||||
/** H2 headings — TOC and the paid-mode paywall preview. */
|
||||
export function extractH2s(data: unknown): string[] {
|
||||
const children = rootChildren(data)
|
||||
if (!children) return []
|
||||
return children
|
||||
.filter(n => n.type === 'heading' && (n.tag || 'h2') === 'h2')
|
||||
.map(n => innerText(n.children))
|
||||
}
|
||||
|
||||
function tagLevel(tag?: string): number {
|
||||
const n = Number.parseInt((tag ?? 'h2').replace('h', ''), 10)
|
||||
return Number.isFinite(n) && n >= 1 && n <= 6 ? n : 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text rendering for llms-full.txt: headings → `## `, paragraphs and
|
||||
* list items → lines, code → fenced blocks, table rows → `| a | b |`.
|
||||
*/
|
||||
export function lexicalToPlain(data: unknown): string {
|
||||
const children = rootChildren(data)
|
||||
if (!children) return ''
|
||||
const lines: string[] = []
|
||||
for (const n of children) {
|
||||
switch (n.type) {
|
||||
case 'heading':
|
||||
lines.push(`${'#'.repeat(tagLevel(n.tag))} ${innerText(n.children)}`.trim())
|
||||
break
|
||||
case 'paragraph':
|
||||
lines.push(innerText(n.children).trim())
|
||||
break
|
||||
case 'list':
|
||||
for (const item of n.children || []) lines.push(`- ${innerText(item.children).trim()}`)
|
||||
break
|
||||
case 'listitem':
|
||||
lines.push(`- ${innerText(n.children).trim()}`)
|
||||
break
|
||||
case 'code':
|
||||
lines.push('```\n' + (n.code ?? innerText(n.children)) + '\n```')
|
||||
break
|
||||
case 'table':
|
||||
for (const row of n.children || []) {
|
||||
const cells = (row.children || []).map(cell => innerText(cell.children).trim().replace(/\|/g, '\\|'))
|
||||
lines.push(`| ${cells.join(' | ')} |`)
|
||||
}
|
||||
break
|
||||
case 'quote':
|
||||
lines.push(`> ${innerText(n.children).trim()}`)
|
||||
break
|
||||
case 'horizontalrule':
|
||||
lines.push('---')
|
||||
break
|
||||
default: {
|
||||
const text = innerText(n.children).trim()
|
||||
if (text) lines.push(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.filter(line => line !== '').join('\n\n')
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { TOPICS, topicsFor, type TopicKey } from '@/lib/topics'
|
||||
|
||||
export type IndexDoc = {
|
||||
id: number | string
|
||||
title: string
|
||||
slug: string
|
||||
dek: string
|
||||
access: 'free' | 'vip'
|
||||
category: string
|
||||
level: string
|
||||
publishedAt: string | null
|
||||
updatedAt: string | null
|
||||
/** Present only when requested via fetchIndexDocs(true). */
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
/** Published posts, public read path — no overrideAccess (drafts stay hidden,
|
||||
* and the content field's own read ACL decides body availability). */
|
||||
export async function fetchIndexDocs(withContent = false): Promise<IndexDoc[]> {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { status: { equals: 'published' } },
|
||||
depth: 0,
|
||||
limit: 500,
|
||||
sort: '-publishedAt',
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
dek: true,
|
||||
access: true,
|
||||
category: true,
|
||||
level: true,
|
||||
publishedAt: true,
|
||||
updatedAt: true,
|
||||
...(withContent ? { content: true } : {}),
|
||||
},
|
||||
})
|
||||
return docs as unknown as IndexDoc[]
|
||||
}
|
||||
|
||||
export const SITE_TITLE = 'أنور بآي — أدلة الذكاء الاصطناعي العملي بالعربية'
|
||||
export const SITE_INTRO = 'دليلك العربي للذكاء الاصطناعي العملي: وكلاء، نماذج، وأدوات.'
|
||||
|
||||
/** Topic-grouped markdown index lines; a post can appear under several topics. */
|
||||
export function renderIndexSections(docs: IndexDoc[], markVip: boolean): string {
|
||||
const lines: string[] = []
|
||||
for (const topic of TOPICS) {
|
||||
const items = docs.filter(doc => topicsFor(doc).includes(topic.key))
|
||||
if (items.length === 0) continue
|
||||
lines.push(`## ${topic.label}`, '')
|
||||
for (const doc of items) {
|
||||
const suffix = markVip && doc.access === 'vip' ? ' (دليل للأعضاء)' : ''
|
||||
lines.push(`- [${doc.title}${suffix}](/posts/${doc.slug}): ${doc.dek}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -1,3 +1,42 @@
|
||||
/* Generic text-only send over the same Cloudflare Email Sending REST call —
|
||||
used by /api/notify for the launch-notification confirmation. Single-shot,
|
||||
honest { sent: false } on any failure; no retry. */
|
||||
export async function sendMail(to: string, subject: string, text: string): Promise<{ sent: boolean }> {
|
||||
const apiToken = process.env.CLOUDFLARE_API_TOKEN
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
if (!apiToken || !accountId) return { sent: false }
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/email/sending/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to,
|
||||
from: { address: process.env.MAIL_FROM ?? 'noreply@anwarpy.ai', name: 'أنور بي آي' },
|
||||
subject,
|
||||
text,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('cloudflare email sending failed:', res.status, await res.text())
|
||||
return { sent: false }
|
||||
}
|
||||
const json = (await res.json()) as { success?: boolean }
|
||||
if (!json.success) {
|
||||
console.error('cloudflare email sending rejected:', JSON.stringify(json))
|
||||
return { sent: false }
|
||||
}
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
console.error('cloudflare email sending threw:', err)
|
||||
return { sent: false }
|
||||
}
|
||||
}
|
||||
|
||||
/* Cloudflare Email Sending (REST) — activates only when CLOUDFLARE_API_TOKEN
|
||||
and CLOUDFLARE_ACCOUNT_ID are set. Without them, sendVerificationEmail
|
||||
returns { sent: false } and the UI degrades to a clear status message; the
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* L-05: reject agent content that looks like a leaked credential before it
|
||||
* is stored or published. `scanForSecrets` returns matched PATTERN NAMES
|
||||
* only — never the matched values (those must not be echoed anywhere).
|
||||
*/
|
||||
|
||||
const PATTERNS: readonly (readonly [string, RegExp])[] = [
|
||||
['aws_access_key', /AKIA[0-9A-Z]{16}/],
|
||||
['pem_private_key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
||||
['openai_api_key', /sk-[A-Za-z0-9]{20,}/],
|
||||
['github_token', /ghp_[A-Za-z0-9]{36}/],
|
||||
['generic_credential', /(?:password|passwd|api[_-]?key|secret)\s*[:=]\s*\S+/i],
|
||||
] as const
|
||||
|
||||
export function scanForSecrets(text: string): string[] {
|
||||
const names: string[] = []
|
||||
for (const [name, re] of PATTERNS) {
|
||||
if (re.test(text)) names.push(name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** Scan the strings an agent can set; returns pattern names across all targets. */
|
||||
export function scanAgentPayload(targets: (string | undefined | null)[]): string[] {
|
||||
const names = new Set<string>()
|
||||
for (const target of targets) {
|
||||
if (typeof target !== 'string') continue
|
||||
for (const name of scanForSecrets(target)) names.add(name)
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
@@ -14,7 +14,6 @@ export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN "full_name" varchar;
|
||||
ALTER TABLE "users" ADD COLUMN "interest" "enum_users_interest";
|
||||
ALTER TABLE "users" ADD COLUMN "level" "enum_users_level";
|
||||
ALTER TABLE "users" ADD COLUMN "goal" "enum_users_goal";
|
||||
@@ -43,7 +42,6 @@ export async function down({ db, payload, req }: MigrateDownArgs): Promise<void>
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_post_likes_fk";
|
||||
|
||||
DROP INDEX "payload_locked_documents_rels_post_likes_id_idx";
|
||||
ALTER TABLE "users" DROP COLUMN "full_name";
|
||||
ALTER TABLE "users" DROP COLUMN "interest";
|
||||
ALTER TABLE "users" DROP COLUMN "level";
|
||||
ALTER TABLE "users" DROP COLUMN "goal";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_posts_content_type" AS ENUM('note', 'tutorial', 'case_study', 'comparison');
|
||||
CREATE TYPE "public"."enum_launch_notifications_source" AS ENUM('subscription');
|
||||
CREATE TABLE "launch_notifications" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"source" "enum_launch_notifications_source" DEFAULT 'subscription',
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "posts" ADD COLUMN "content_type" "enum_posts_content_type";
|
||||
ALTER TABLE "posts" ADD COLUMN "author_name" varchar;
|
||||
ALTER TABLE "posts" ADD COLUMN "source_id" varchar;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "launch_notifications_id" integer;
|
||||
CREATE UNIQUE INDEX "launch_notifications_email_idx" ON "launch_notifications" USING btree ("email");
|
||||
CREATE INDEX "launch_notifications_updated_at_idx" ON "launch_notifications" USING btree ("updated_at");
|
||||
CREATE INDEX "launch_notifications_created_at_idx" ON "launch_notifications" USING btree ("created_at");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_launch_notifications_fk" FOREIGN KEY ("launch_notifications_id") REFERENCES "public"."launch_notifications"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "posts_source_id_idx" ON "posts" USING btree ("source_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_launch_notifications_id_idx" ON "payload_locked_documents_rels" USING btree ("launch_notifications_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "launch_notifications" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "launch_notifications" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_launch_notifications_fk";
|
||||
|
||||
DROP INDEX "posts_source_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_launch_notifications_id_idx";
|
||||
ALTER TABLE "posts" DROP COLUMN "content_type";
|
||||
ALTER TABLE "posts" DROP COLUMN "author_name";
|
||||
ALTER TABLE "posts" DROP COLUMN "source_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "launch_notifications_id";
|
||||
DROP TYPE "public"."enum_posts_content_type";
|
||||
DROP TYPE "public"."enum_launch_notifications_source";`)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import * as migration_20261003_111451 from './20261003_111451';
|
||||
import * as migration_20261003_133758 from './20261003_133758';
|
||||
import * as migration_20261003_201454 from './20261003_201454';
|
||||
import * as migration_20261009_130817 from './20261009_130817';
|
||||
import * as migration_20261010_142214 from './20261010_142214';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
@@ -34,6 +35,11 @@ export const migrations = [
|
||||
{
|
||||
up: migration_20261009_130817.up,
|
||||
down: migration_20261009_130817.down,
|
||||
name: '20261009_130817'
|
||||
name: '20261009_130817',
|
||||
},
|
||||
{
|
||||
up: migration_20261010_142214.up,
|
||||
down: migration_20261010_142214.down,
|
||||
name: '20261010_142214'
|
||||
},
|
||||
];
|
||||
|
||||
@@ -10,6 +10,7 @@ import Media from './collections/Media'
|
||||
import Files from './collections/Files'
|
||||
import PostLikes from './collections/PostLikes'
|
||||
import Comments from './collections/Comments'
|
||||
import LaunchNotifications from './collections/LaunchNotifications'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
@@ -20,7 +21,7 @@ export default buildConfig({
|
||||
meta: { titleSuffix: '— أنور بي آي' },
|
||||
},
|
||||
secret: process.env.PAYLOAD_SECRET || 'dev-secret-change-me',
|
||||
collections: [Users, Posts, Media, Files, PostLikes, Comments],
|
||||
collections: [Users, Posts, Media, Files, PostLikes, Comments, LaunchNotifications],
|
||||
editor: lexicalEditor(),
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
|
||||
Reference in New Issue
Block a user