---
title: 'مكوّنات React المخصّصة'
description: 'اكتب مكوّناتك الخاصة بـ React، واستخدمها في Markdown كالمكوّنات المدمجة، وأعطِ كلًّا منها بديلًا يعمل من دون JavaScript وصيغة Markdown للوكلاء.'
url: 'https://larecipe.saleem.dev/ar/3.x/customize/custom-components'
version: 3.x
locale: ar
last_updated: '2026-10-19'
---

> Docs index: https://larecipe.saleem.dev/3.x/llms.txt · MCP: https://larecipe.saleem.dev/mcp · Skill: https://larecipe.saleem.dev/skill.md

# مكوّنات React المخصّصة

حين لا تكفي المكوّنات المدمجة (نموذج اشتراك في نشرة بريدية، أو جدول أسعار، أو شارة حالة حيّة)، اكتب مكوّنك الخاص بـ React واستخدمه في Markdown كأي مكوّن مدمج: `<Subscribe list="larecipe" />`. للمكوّن ثلاثة أوجه، وتكتبها كلها:

- **React**، للقرّاء الذين يشغّل متصفحهم JavaScript.
- **HTML**، بديل (fallback) من الخادم للقرّاء بلا JavaScript، ولمحركات الزحف، وللرسم الأول.
- **Markdown**، ما يحصل عليه الوكلاء حين يقرؤون الصفحة بصيغة `.md` أو عبر MCP.

نموذج النشرة البريدية في آخر [المقدمة](https://larecipe.saleem.dev/ar/3.x/index.md) واحد منها. وهذه الصفحة تبنيه في أربع خطوات، مباشرةً من كود هذا الموقع:

1. [اكتب مكوّن React](https://larecipe.saleem.dev/ar/3.x/customize/custom-components#the-react-component).
2. [سجّله في ملف دخول الامتداد](https://larecipe.saleem.dev/ar/3.x/customize/custom-components#the-extension-entry).
3. [عرّفه في PHP](https://larecipe.saleem.dev/ar/3.x/customize/custom-components#the-php-definition)، مع بديله وصيغة Markdown الخاصة به.
4. [ابنِه](https://larecipe.saleem.dev/ar/3.x/customize/custom-components#build-it) باستخدام Vite.

ثم [استخدمه](https://larecipe.saleem.dev/ar/3.x/customize/custom-components#use-it) في صفحة، أو في كل صفحة.

**كل ملف يحتاج إليه نموذج Subscribe**

- app/
  - Http/Controllers/SubscribeController.php
  - Providers/AppServiceProvider.php
- resources/
  - larecipe/
    - extension.tsx
    - Subscribe.tsx
    - subscribe.css
  - views/
    - docs/subscribe.blade.php
    - newsletter.blade.php
- routes/web.php
- vite.config.js

## مكوّن React

المكوّن المخصّص مكوّن React عادي. خصائصه (props) هي سمات الوسم، و Markdown الواقع بين وسم فتح ووسم إغلاق يصل على شكل `children`. وكل ما يستورده من `@larecipe/ui` يوفّره LaRecipe وقت التشغيل.

```tsx title="resources/larecipe/Subscribe.tsx"
import { useState, type FormEvent } from 'react'
import { Button, Icon, Input, request, useLaRecipe } from '@larecipe/ui'

/** The form's words in each language the site serves (lang/{en,ar}/newsletter.php on the server). */
const STRINGS = {
  en: {
    label: 'Newsletter',
    title: 'Stay in the loop',
    body: 'LaRecipe release notes by email. Unsubscribe any time.',
    email: 'Email address',
    submit: 'Subscribe',
    sending: 'Subscribing',
    doneTitle: 'You are on the list',
    doneBody: (email: string) => `The next release note goes to ${email}.`,
    failed: 'That did not work. Please try again.',
    note: 'This form is a custom React component this site adds to LaRecipe.',
    noteLink: 'See how it is built',
  },
  ar: {
    label: 'النشرة البريدية',
    title: 'ابقَ على اطلاع',
    body: 'ملاحظات إصدارات LaRecipe عبر البريد الإلكتروني. يمكنك إلغاء الاشتراك في أي وقت.',
    email: 'عنوان البريد الإلكتروني',
    submit: 'اشترك',
    sending: 'جارٍ الاشتراك',
    doneTitle: 'أنت الآن في القائمة',
    doneBody: (email: string) => `ستصل ملاحظة الإصدار التالي إلى ${email}.`,
    failed: 'لم ينجح ذلك. حاول مرة أخرى.',
    note: 'هذا النموذج مكوّن React مخصّص يضيفه هذا الموقع إلى LaRecipe.',
    noteLink: 'شاهد كيف بُني',
  },
}

/** Where "See how it is built" goes: the custom components guide, in the reader's language and version. */
function guideUrl(base: string, locale: string, version: string): string {
  return `${base.replace(/\/$/, '')}${locale === 'ar' ? '/ar' : ''}/${version}/customize/custom-components`
}

/**
 * The newsletter form on every docs page (the article.after insert point) and
 * inline on the Introduction (<Subscribe />).
 *
 * React comes from the page's import map, so this shares LaRecipe's React.
 * useLaRecipe() gives the page and its locale; request() posts to our own
 * route with the session cookie and the XSRF token, and hands back Laravel's
 * validation errors. The markup matches the server fallback in
 * resources/views/docs/subscribe.blade.php.
 */
export function Subscribe({ list = 'larecipe', showcase = false }: { list?: string; showcase?: boolean }) {
  const { page, locale, boot, version } = useLaRecipe()
  const lang = locale.code === 'ar' ? 'ar' : 'en'
  const t = STRINGS[lang]
  const [email, setEmail] = useState('')
  const [status, setStatus] = useState<'idle' | 'sending' | 'done'>('idle')
  const [error, setError] = useState<string | null>(null)

  async function submit(event: FormEvent) {
    event.preventDefault()
    setStatus('sending')
    setError(null)

    const response = await request<{ subscribed: boolean }>('/newsletter/subscribe', {
      method: 'POST',
      body: { email, list, source: page.path, locale: lang },
    })

    if (response.ok) {
      setStatus('done')
      return
    }

    setError(response.errors?.email?.[0] ?? response.message ?? t.failed)
    setStatus('idle')
  }

  const note = showcase ? (
    <p className="lr-subscribe-note">
      {t.note} <a href={guideUrl(boot.urls.base, lang, version)}>{t.noteLink}</a>
    </p>
  ) : null

  const head = (title: string, body: string) => (
    <div className="lr-subscribe-head">
      <span className="lr-subscribe-icon" aria-hidden="true">
        <Icon name="Mail" size={18} />
      </span>
      <div className="lr-subscribe-copy">
        <p className="lr-subscribe-label">{t.label}</p>
        <p className="lr-subscribe-title">{title}</p>
        <p className="lr-subscribe-body">{body}</p>
      </div>
    </div>
  )

  if (status === 'done') {
    return (
      <div className="lr-subscribe-wrap">
        <div className="lr-subscribe" data-lr-subscribe="react" role="status">
          {head(t.doneTitle, t.doneBody(email))}
        </div>
        {note}
      </div>
    )
  }

  return (
    <div className="lr-subscribe-wrap">
      <form className="lr-subscribe" data-lr-subscribe="react" onSubmit={submit}>
        {head(t.title, t.body)}
        <div className="lr-subscribe-row">
          <label className="lr-subscribe-field">
            <span className="lr-sr">{t.email}</span>
            <Input
              type="email"
              name="email"
              required
              autoComplete="email"
              placeholder="you@example.com"
              dir="ltr"
              value={email}
              aria-invalid={error ? true : undefined}
              onChange={(event) => setEmail(event.target.value)}
            />
          </label>
          <Button type="submit" variant="primary" loading={status === 'sending'}>
            {status === 'sending' ? t.sending : t.submit}
          </Button>
        </div>
        {error && (
          <p className="lr-subscribe-error" role="alert">
            {error}
          </p>
        )}
      </form>
      {note}
    </div>
  )
}
```

**ما الذي يعطيه @larecipe/ui لمكوّنك**

| التصدير | ما يفعله |
| --- | --- |
| `useLaRecipe()` | الصفحة الحالية `page` (المسار والعنوان والروابط والإصدار واللغة و `meta`)، والمجموعة `set` (المفتاح والاسم والإصدارات واللغات)، واللغة `locale` (الرمز والاتجاه)، والمظهر `theme` (الوضع وتفضيل القارئ)، والمستخدم المسجّل `user` حين تشاركه، و `t` لنصوص واجهة LaRecipe بلغة الصفحة، ومساعد `navigate(url)`. |
| `request(path, options)` | طلب fetch إلى مسارات تطبيقك أنت. يرفض المصادر الأخرى، ويرسل ملف تعريف الارتباط الخاص بالجلسة، و `XSRF-TOKEN` الخاص بـ Laravel على شكل `X-XSRF-TOKEN`، و `Accept: application/json`، ويرمّز `body` بصيغة JSON، ويعيد `{ ok: true, status, data }` أو `{ ok: false, status, data, errors, message }`، مع أخطاء التحقق 422 من Laravel في `errors`. |
| `Button`، `LinkButton`، `Input`، `Textarea`، `Field`، `Callout`، `Badge`، `Card`، `Kbd`، `Loader`، `Ticket`، `Perforation` | الأجزاء التي بُنيت منها الواجهة المدمجة، في المظهرين ومن اليمين إلى اليسار. ويأخذ `Button` الخاصية `variant` (`primary` و `secondary` و `outline` و `ghost` و `ink`)، و `size`، و `loading`. |
| `Icons`، `Icon` | مجموعة الأيقونات الخاصة بالواجهة. |
| `Slot`، `useSlotDefault()` | تستبدل منطقة مدمجة في الصفحة مع إمكانية تصيير الأصلية. راجع [نقاط الإدراج والفتحات](https://larecipe.saleem.dev/ar/3.x/customize/insert-points.md). |
| `defineExtension()` | تسجّل مكوّناتك ونقاط الإدراج واستبدالات الفتحات في الصفحة. |

**شارك المستخدم المسجّل وقيم الصفحة**

لا يُشارَك المستخدم المسجّل إلا حين تطلب ذلك، حقلًا حقلًا:

```php title="app/Providers/AppServiceProvider.php"
LaRecipe::shareUser(fn ($user) => ['name' => $user->name]);
```

والقيم التي يحتاج إليها مكوّن من صفحة بعينها توضع تحت `meta:` في ترويسة تلك الصفحة، وتصل على شكل `page.meta`:

```yaml
---
title: Pricing
meta:
  plan: enterprise
---
```

`meta` هي الترويسة الوحيدة خارج مفاتيح LaRecipe التي تصل إلى المتصفح. أما المفاتيح المجهولة الأخرى فتبقى على الخادم، لأنها غالبًا تحمل ملاحظات للفريق، مثل المسؤول عن الصفحة أو رقم تذكرة. راجع [ترويسة الصفحة](https://larecipe.saleem.dev/ar/3.x/reference/frontmatter.md#unknown-keys).

## ملف دخول الامتداد

ملف دخول واحد يسجّل كل المكوّنات المخصّصة في تطبيقك:

```tsx title="resources/larecipe/extension.tsx"
import { defineExtension } from '@larecipe/ui'
import { DocsFlow } from './DocsFlow'
import { InstallTabs } from './InstallTabs'
import { Subscribe } from './Subscribe'
import './docs-flow.css'
import './install-tabs.css'
import './subscribe.css'
// Server-only components (no React): only their styles ship here.
import './byline.css'
import './featured-on.css'
import './features.css'

// Our LaRecipe extension: the React side of the components this site adds.
// Each one is also defined in PHP (AppServiceProvider) with its props, its
// server fallback and its Markdown form.
export default defineExtension({ components: { DocsFlow, InstallTabs, Subscribe } })
```

يقرأ CSS الخاص به [متغيرات المظهر](https://larecipe.saleem.dev/ar/3.x/customize/theme.md) في LaRecipe، فيتبع النموذج لون الفريق المميِّز والوضع الداكن والصفحات من اليمين إلى اليسار من دون قواعد إضافية. والقواعد نفسها تنسّق نموذج React والبديل بصيغة HTML، لأن كليهما يستخدم أسماء الأصناف نفسها.

**resources/larecipe/subscribe.css**

```css title="resources/larecipe/subscribe.css"
/*
 * The newsletter form. It reads LaRecipe's theme tokens, so it follows the
 * site's accent, dark mode and direction. The same rules style the server
 * fallback (no JavaScript) and the React form.
 */
.lr-subscribe-wrap {
  margin-block: 40px 8px;
}

.lr-subscribe {
  display: grid;
  gap: 20px;
  padding: 24px;
  border: 1px solid color-mix(in srgb, var(--lr-accent-current) 20%, var(--lr-hairline));
  border-radius: var(--lr-radius-lg);
  background:
    radial-gradient(120% 160% at 100% 0%, color-mix(in srgb, var(--lr-accent-current) 9%, transparent), transparent 62%),
    var(--lr-paper);
  box-shadow: var(--lr-shadow-xs);
}

[dir='rtl'] .lr-subscribe {
  background:
    radial-gradient(120% 160% at 0% 0%, color-mix(in srgb, var(--lr-accent-current) 9%, transparent), transparent 62%),
    var(--lr-paper);
}

.lr-prose .lr-subscribe p,
.lr-subscribe p {
  margin: 0;
}

.lr-subscribe-head {
  display: flex;
  align-items: flex-start;
  gap: 14px;
}

.lr-subscribe-icon {
  flex: none;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: var(--lr-radius-md);
  background: var(--lr-accent-soft);
  color: var(--lr-accent-current);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lr-accent-current) 18%, transparent);
}

.lr-subscribe-copy {
  display: grid;
  gap: 4px;
  min-width: 0;
}

.lr-subscribe-label {
  font-family: var(--lr-font-mono);
  font-size: 11px;
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--lr-accent-current);
}

.lr-subscribe .lr-subscribe-title {
  margin-block-start: 2px;
  font-size: 17px;
  font-weight: 600;
  line-height: 1.35;
  letter-spacing: -0.01em;
  color: var(--lr-ink);
}

.lr-subscribe .lr-subscribe-body {
  font-size: 14px;
  line-height: 1.5;
  color: var(--lr-ink-muted);
}

/* The field and the button share one bordered row, focused as one. */
.lr-subscribe-row {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px;
  border: 1px solid var(--lr-hairline);
  border-radius: calc(var(--lr-radius-md) + 4px);
  background: var(--lr-surface);
  transition: border-color var(--lr-duration-hover) var(--lr-ease), box-shadow var(--lr-duration-hover) var(--lr-ease);
}

.lr-subscribe-row:hover {
  border-color: var(--lr-hairline-strong);
}

.lr-subscribe-row:focus-within {
  border-color: var(--lr-accent-current);
  box-shadow: 0 0 0 3px var(--lr-accent-ring);
}

.lr-subscribe-field {
  flex: 1 1 auto;
  display: flex;
  min-width: 0;
}

.lr-subscribe-field .lr-input,
.lr-subscribe-field .lr-input:hover,
.lr-subscribe-field .lr-input:focus,
.lr-subscribe-field .lr-input:focus-visible {
  width: 100%;
  border-color: transparent;
  background: transparent;
  outline: none;
  box-shadow: none;
}

.lr-subscribe-row .lr-button {
  flex: none;
}

.lr-subscribe .lr-subscribe-error {
  font-size: 14px;
  line-height: 1.5;
  color: var(--lr-danger);
}

/* The quiet line under the form on the Introduction (showcase). */
.lr-prose .lr-subscribe-note,
.lr-subscribe-note {
  margin: 10px 4px 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--lr-ink-muted);
}

.lr-prose .lr-subscribe-note a,
.lr-subscribe-note a {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--lr-hairline-strong);
  text-underline-offset: 3px;
}

.lr-prose .lr-subscribe-note a:hover,
.lr-subscribe-note a:hover {
  color: var(--lr-ink);
  text-decoration-color: currentColor;
}

:root:lang(ar) .lr-subscribe-label {
  font-family: var(--lr-font-sans);
  font-size: 12.5px;
  letter-spacing: normal;
  text-transform: none;
}
```

## التعريف في PHP

كل مكوّن مخصّص يحتاج إلى تعريف في PHP، عادةً في `AppServiceProvider::boot()`. يسمّي التعريف المكوّن، ويصف خصائصه، ويعطي بديله بصيغة HTML وصيغة Markdown الخاصة به:

```php title="app/Providers/AppServiceProvider.php"
LaRecipe::component('Subscribe')
    ->description('A sign-up form for the LaRecipe newsletter.')
    ->props([
        'list' => ['type' => 'string', 'default' => 'larecipe', 'description' => 'The newsletter list to join'],
        'showcase' => ['type' => 'boolean', 'default' => false, 'description' => 'Add a faded line under the form that links to how it is built'],
    ])
    ->fallback(function (array $props, string $children, RenderedComponent $component) {
        $locale = in_array($component->locale, SubscribeController::LOCALES, true) ? $component->locale : 'en';

        return view('docs.subscribe', [
            'list' => $props['list'],
            'locale' => $locale,
            'showcase' => (bool) $props['showcase'],
            'guide' => url(($locale === 'ar' ? 'ar/' : '').($component->version ?? '3.x').'/customize/custom-components'),
        ]);
    })
    ->markdown(function (array $props, string $children, RenderedComponent $component) {
        $locale = in_array($component->locale, SubscribeController::LOCALES, true) ? $component->locale : 'en';
        $line = __('newsletter.markdown', ['url' => url('/newsletter')], $locale);

        if (! $props['showcase']) {
            return $line;
        }

        return $line."\n\n".__('newsletter.note', [], $locale).' '.__('newsletter.note_link', [], $locale).': '
            .url(($locale === 'ar' ? 'ar/' : '').($component->version ?? '3.x').'/customize/custom-components');
    })
    ->island();
```

تصف `props()` كل خاصية، وتعيد `fallback()` صيغة HTML من الخادم، وتعيد `markdown()` ما يقرؤه الوكلاء، وتسمح `island()` لـ React بأن يُركَّب فوق البديل. والوسم الذي لا تعريف له في PHP مكوّن مجهول: يُصيَّر نصًا مُهرَّبًا، ويبلّغ عنه `php artisan larecipe:validate` بالقاعدة `components.unknown`. فلا تستطيع الصفحة أبدًا أن تطلب من المتصفح تشغيل كود لم يسجّله تطبيقك.

**كل دوال التعريف**

| الدالة | ما تضبطه |
| --- | --- |
| `props([...])` | `type` لكل خاصية، واختياريًا `default` و `required` و `enum` و `description` (أو الصيغة المختصرة `'list' => 'string'`). ويفحصها `larecipe:validate` وكل عملية تصيير: فالخاصية المطلوبة الناقصة أو القيمة الواقعة خارج `enum` خطأ من نوع `components.props`. وفي مجموعة المستودع، تصل إلى React الخصائص التي لا تصفها أيضًا. أما في مجموعة قاعدة البيانات، التي قد يكون كتّابها أقل موثوقية من قاعدة الكود لديك، فتُحذف، لذا صِف كل خاصية تقرؤها. |
| `fallback(fn (array $props, string $children, RenderedComponent $context) => ...)` | صيغة HTML من الخادم. أعد نصًا، أو view، أو أي شيء `Htmlable`. و `$children` هو Markdown المُصيَّر داخل الوسم، و `$context` يحمل المجموعة والإصدار واللغة ومسار الصفحة. |
| `markdown(fn (array $props, string $children, $context) => ...)` | السطر أو الكتلة التي يقرؤها الوكلاء. ومن دونها، يحصل الوكلاء على المحتوى الداخلي. |
| `island()` | تغلّف البديل في عنصر نائب يُركَّب فيه React. ومن دونها، يبقى الوسم المضمَّن في النص على صيغة HTML البديلة ولا يعمل React أبدًا. وفي نقاط الإدراج، تُغلَّف المكوّنات دائمًا. |
| `raw()` | تُبقي المحتوى الواقع بين الوسمين نصًا حرفيًا بدلًا من Markdown. |
| `description('...')` | جملة واحدة لمرجع المكوّنات المولَّد الذي يقرؤه الوكلاء. |

### البديل

البديل هو ما يحصل عليه القارئ بلا JavaScript، فاجعله يعمل وحده. وبديل Subscribe نموذج عادي يرسل إلى المسار نفسه:

```blade title="resources/views/docs/subscribe.blade.php"
{{--
    The Subscribe component without JavaScript: a plain form that posts to
    /newsletter/subscribe and lands on /newsletter with the outcome. Its markup
    matches resources/larecipe/Subscribe.tsx, so React mounts without a shift,
    and it speaks the page's language ($locale: en or ar).
    The page is cached and shared, so the CSRF token comes from csrfField(),
    which LaRecipe fills in on every response.
--}}
<div class="lr-subscribe-wrap">
    <form class="lr-subscribe" method="post" action="/newsletter/subscribe" data-lr-subscribe="html">
        {!! \LaRecipe\Facades\LaRecipe::csrfField() !!}
        <input type="hidden" name="list" value="{{ $list }}">
        <input type="hidden" name="locale" value="{{ $locale }}">
        <div class="lr-subscribe-head">
            <span class="lr-subscribe-icon" aria-hidden="true">@include('larecipe::partials.icon', ['name' => 'Mail', 'iconSize' => 18, 'iconClass' => ''])</span>
            <div class="lr-subscribe-copy">
                <p class="lr-subscribe-label">{{ __('newsletter.label', [], $locale) }}</p>
                <p class="lr-subscribe-title">{{ __('newsletter.title', [], $locale) }}</p>
                <p class="lr-subscribe-body">{{ __('newsletter.body', [], $locale) }}</p>
            </div>
        </div>
        <div class="lr-subscribe-row">
            <label class="lr-subscribe-field">
                <span class="lr-sr">{{ __('newsletter.email', [], $locale) }}</span>
                <input class="lr-input" type="email" name="email" required autocomplete="email" placeholder="you@example.com" dir="ltr">
            </label>
            <button class="lr-button lr-button-primary" type="submit">{{ __('newsletter.submit', [], $locale) }}</button>
        </div>
    </form>
    @if ($showcase)
        <p class="lr-subscribe-note">{{ __('newsletter.note', [], $locale) }} <a href="{{ $guide }}">{{ __('newsletter.note_link', [], $locale) }}</a></p>
    @endif
</div>
```

المقالات المُصيَّرة تُخزَّن مؤقتًا ويتشاركها القرّاء، لذا لا يجوز أن يثبّت البديل داخله أي شيء يخصّ طلبًا واحدًا. ويطبع `LaRecipe::csrfField()` عنصرًا نائبًا يملؤه LaRecipe برمز CSRF الخاص بالقارئ في كل استجابة. ومن دون JavaScript، يرسل النموذج وينتهي في [صفحة النشرة البريدية](https://larecipe.saleem.dev/newsletter)، وهي صفحة عادية تُظهر هل نجح الاشتراك.

### صيغة Markdown

لا ينبغي للوكلاء أن يملؤوا النماذج، لذا فصيغة Markdown الخاصة بـ Subscribe سطر واحد فيه رابط، ومعه السطر الباهت حين تُضبط الخاصية `showcase`. اقرأ [المقدمة بصيغة Markdown](https://larecipe.saleem.dev/3.x/index.md) وستجده قرب النهاية:

```markdown
LaRecipe release notes by email. Unsubscribe any time. Subscribe with your email address: https://larecipe.saleem.dev/newsletter

This form is a custom React component this site adds to LaRecipe. See how it is built: https://larecipe.saleem.dev/3.x/customize/custom-components
```

**بلغة القارئ**

يستطيع المكوّن أن يتبع لغة الصفحة كما تفعل الواجهة المدمجة. فـ Subscribe يقرأ `locale` من `useLaRecipe()` في React، و `$context->locale` في بديله، ثم يختار كلماته من `lang/en/newsletter.php` أو `lang/ar/newsletter.php`. وفي [المقدمة العربية](https://larecipe.saleem.dev/ar/3.x) يُقرأ النموذج نفسه من اليمين إلى اليسار، بالعربية، ويجيب المسار بالعربية أيضًا.

**المسار الذي يرسل إليه النموذج**

يرسل نموذج React والبديل كلاهما إلى مسار عادي في تطبيقك، مع الـ middleware الخاص بك. وهذا هو ذلك المسار، مقروءًا من التطبيق العامل:

**Route** `POST /newsletter/subscribe`

| Method | URI | Middleware |
| --- | --- | --- |
| `POST` | `/newsletter/subscribe` | `web, throttle` |

يتحقق الـ controller من العنوان ويرسله إلى Nashra. ولا يفشل أبدًا بخطأ 500: فمفتاح API الناقص أو الخدمة التي يتعذّر الوصول إليها يعودان بجملة واحدة لطيفة، بصيغة JSON لـ `request()`، وعلى شكل إعادة توجيه للنموذج العادي.

```php title="app/Http/Controllers/SubscribeController.php"
<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Nashra\Sdk\Exceptions\AuthenticationException;
use Nashra\Sdk\Exceptions\AuthorizationException;
use Nashra\Sdk\Exceptions\RateLimitException;
use Nashra\Sdk\Exceptions\ValidationException;
use Nashra\Sdk\Facades\Nashra;
use Throwable;

/**
 * POST /newsletter/subscribe: adds a reader to the LaRecipe newsletter on Nashra.
 *
 * Called by the Subscribe component in two ways: as JSON from the React form
 * (through request() from @larecipe/ui), and as a plain form post from the
 * server fallback when JavaScript is off. A plain post lands on /newsletter,
 * which can show the outcome; the docs page itself is cached HTML and cannot.
 *
 * It answers in the page's language (English or Arabic), and it never fails
 * with a 500: a missing SDK or API key, an unreachable API or a rejected
 * address each come back as one plain sentence.
 */
final class SubscribeController
{
    public const LOCALES = ['en', 'ar'];

    public function __invoke(Request $request): JsonResponse|RedirectResponse
    {
        $locale = in_array($request->input('locale'), self::LOCALES, true) ? $request->input('locale') : 'en';

        $data = $request->validate([
            'email' => ['required', 'string', 'email', 'max:254'],
            'list' => ['nullable', 'string', 'max:64'],
            'source' => ['nullable', 'string', 'max:255'],
            'locale' => ['nullable', 'string'],
        ], [
            'email.required' => __('newsletter.required', [], $locale),
            'email.email' => __('newsletter.invalid', [], $locale),
        ]);

        [$subscribed, $message, $field] = $this->subscribe($data['email'], $locale);

        if ($request->expectsJson()) {
            return $subscribed
                ? response()->json(['subscribed' => true])
                : response()->json([
                    'subscribed' => false,
                    'message' => $message,
                    'errors' => $field ? [$field => [$message]] : null,
                ], $field ? 422 : 503);
        }

        $page = redirect()->route('newsletter', $locale === 'en' ? [] : ['lang' => $locale]);

        return $subscribed
            ? $page->with('subscribed', $data['email'])
            : $page->withInput()->withErrors(['email' => $message]);
    }

    /** @return array{bool, ?string, ?string} subscribed, a message for the reader, the field it is about */
    private function subscribe(string $email, string $locale): array
    {
        if (! class_exists(Nashra::class) || blank(config('nashra.api_key'))) {
            return [false, __('newsletter.not_connected', [], $locale), null];
        }

        try {
            Nashra::subscribers()->create(['email' => $email]);

            return [true, null, null];
        } catch (ValidationException $exception) {
            return [false, $exception->errors()['email'][0] ?? __('newsletter.rejected', [], $locale), 'email'];
        } catch (RateLimitException) {
            return [false, __('newsletter.rate_limited', [], $locale), null];
        } catch (AuthenticationException|AuthorizationException $exception) {
            report($exception);

            return [false, __('newsletter.not_connected', [], $locale), null];
        } catch (Throwable $exception) {
            report($exception);

            return [false, __('newsletter.unavailable', [], $locale), null];
        }
    }
}
```

## ابنِه

المكوّنات المخصّصة هي الجزء الوحيد من LaRecipe الذي يحتاج إلى Node، ووقت البناء فقط، على جهازك أو في CI. أضف `@larecipe/vite` و `@larecipe/ui` اعتمادياتٍ للتطوير، ثم أضف الإضافة إلى إعدادات Vite لديك. ويعمل `@larecipe/vite` مع Vite 6 و 7 و 8. وهذه إعدادات هذا الموقع نفسه:

```javascript title="vite.config.js"
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
import larecipe from '@larecipe/vite';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
        tailwindcss(),
        // Builds our LaRecipe extension (the React side of the Subscribe component)
        // into public/build/larecipe/, which LaRecipe loads on every docs page.
        larecipe({ input: 'resources/larecipe/extension.tsx' }),
    ],
    server: {
        watch: {
            ignored: ['**/storage/framework/views/**'],
        },
    },
});
```

ثم يكتب `npm run build` الامتداد في `public/build/larecipe/`. ولا يشغّل خادمك Node أبدًا: فالملفات المبنية ثابتة. ويبحث LaRecipe عن `public/build/larecipe/manifest.json` في كل صفحة توثيق، وحين يجد مدخل الامتداد فيه، يضيف ملف أنماطه ووحدته بعد بيئة التشغيل الخاصة به مباشرة. فلا وسم script تضيفه بيدك.

**نسخة واحدة من React في الصفحة**

البناء وحدة ES مع ملف manifest. وتُستبعد `react` و `react-dom` و `@larecipe/ui` من الحزمة عن قصد. فكل صفحة توثيق تطبع import map قبل سكربتات LaRecipe، يوجّه الأسماء المجرّدة `react` و `react/jsx-runtime` و `react-dom` و `react-dom/client` و `@larecipe/ui` إلى الملفات المبنية الخاصة بـ LaRecipe:

```html
<script type="importmap">{"imports":{"react":"/larecipe-assets/{build}/assets/react-….js","@larecipe/ui":"/larecipe-assets/{build}/assets/larecipe-ui-….js", "…": "…"}}</script>
```

يُبقي امتدادك هذه الاستيرادات مجرّدة، فيحلّها المتصفح إلى الوحدات نفسها التي يستخدمها LaRecipe. فتوجد نسخة واحدة من React وسجلّ واحد للمكوّنات، وتعمل الـ hooks مثل `useLaRecipe()` داخل مكوّناتك. وتعمل `defineExtension()` سواء حُمّلت وحدتك قبل بيئة تشغيل LaRecipe أو بعدها.

## استخدمه

**داخل النص، في Markdown.** اكتب الوسم في سطر مستقل، مع أسطر فارغة حوله، كأي مكوّن مدمج. والخصائص نصوص:

```markdown
<Subscribe list="larecipe" />
```

**في كل صفحة.** ضعه في نقطة إدراج في `larecipe.json`، كما يفعل هذا الموقع بـ `"inserts": { "article.after": ["Subscribe"] }`. راجع [نقاط الإدراج والفتحات](https://larecipe.saleem.dev/ar/3.x/customize/insert-points.md).

| القارئ | يحصل على |
| --- | --- |
| متصفح مع JavaScript | نموذج React، الذي يرسل بـ `request()` ويجيب في مكانه |
| متصفح بلا JavaScript، أو محرك زحف | النموذج البديل، الذي يرسل وينتهي في `/newsletter` |
| وكيل يقرأ `.md` أو MCP | سطر واحد: رابط النشرة البريدية |

## التالي

- [نقاط الإدراج والفتحات](https://larecipe.saleem.dev/ar/3.x/customize/insert-points.md): ضع مكوّنًا في كل صفحة.
- [المظهر](https://larecipe.saleem.dev/ar/3.x/customize/theme.md): القيم ومتغيرات CSS التي تستطيع مكوّناتك قراءتها.
- [مرجع المكوّنات](https://larecipe.saleem.dev/ar/3.x/reference/components.md): المكوّنات المدمجة و HTML الذي تُصيّره.
