1 estrella en GitHub = 1 componente nuevo.

Dar una estrella
E-commerce

Shop Newsletter Editorial

Suscripción editorial de gran tamaño con campo subrayado, consentimiento y validación en línea.

newsletterformecommercecta
Vista Previa en Vivo

Letters from the studio

One email a month: new pieces before anyone else, the people who make them, and 10% off your first order.

Uso

Copia y pega este código directamente en tu componente React:

tsx
import { ShopNewsletterEditorial } from 'react-component-kit-ui/sections';

<ShopNewsletterEditorial title="Letters from the *studio*" variant="accent" />

Instalación Manual

Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/sections/ShopNewsletterEditorial.tsx + ShopNewsletterEditorial.css:

ShopNewsletterEditorial.tsx
'use client';

import { useEffect, useRef } from 'react';
import './ShopNewsletterEditorial.css';

/** class:list → className */
function cx(...args: unknown[]): string {
  const out: string[] = [];
  const walk = (v: unknown) => {
    if (!v) return;
    if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
    else if (Array.isArray(v)) v.forEach(walk);
    else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
  };
  args.forEach(walk);
  return out.join(' ');
}

/**
 * ShopNewsletterEditorial — Oversized editorial sign-up: large serif statement on one side,
 * underline-style email field, consent note and inline success/error messages on the other.
 *
 * @param {string} title - Statement (use *asterisks* for italics). Default 'Letters from the *studio*'.
 * @param {string} text - Supporting copy.
 * @param {string} action - Form action. With '#' the form validates and confirms locally. Default '#'.
 * @param {string} buttonLabel - Submit label. Default 'Subscribe'.
 * @param {string} consent - Small print under the field.
 * @param {string} successText - Message after a valid submit.
 * @param {string} variant - Style (plain|inverted|accent). Default 'plain'.
 * @param {string} size - Type scale (sm|md|lg). Default 'md'.
 * @param {string} id - Base id. Default 'shop-news'.
 * @param {string} className - Extra class.
 *
 * Accessibility: labelled email input with autocomplete, aria-invalid + described error,
 * messages in a polite live region.
 */
export interface ShopNewsletterEditorialProps {
  title?: string;
  text?: string;
  action?: string;
  buttonLabel?: string;
  consent?: string;
  successText?: string;
  variant?: 'plain' | 'inverted' | 'accent';
  size?: 'sm' | 'md' | 'lg';
  id?: string;
  className?: string;
}

export function ShopNewsletterEditorial({
  title = 'Letters from the *studio*',
  text = 'One email a month: new pieces before anyone else, the people who make them, and 10% off your first order.',
  action = '#',
  buttonLabel = 'Subscribe',
  consent = 'By subscribing you accept our privacy policy. Unsubscribe in one click.',
  successText = 'Thank you — your first letter is on its way.',
  variant = 'plain',
  size = 'md',
  id = 'shop-news',
  className = '',
}: ShopNewsletterEditorialProps) {
  const parts = title.split(/\*(.+?)\*/g);

  // Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
  const mounted = useRef(false);
  useEffect(() => {
    if (mounted.current) return;
    mounted.current = true;
    document.querySelectorAll<HTMLFormElement>('form[data-news]:not([data-bound])').forEach((form) => {
        form.dataset.bound = '';
        const input = form.querySelector<HTMLInputElement>('input[type="email"]')!;
        const msg = form.querySelector<HTMLElement>('[data-msg]')!;
        form.addEventListener('submit', (e) => {
          const local = form.getAttribute('action') === '#';
          if (!input.checkValidity()) {
            e.preventDefault();
            input.setAttribute('aria-invalid', 'true');
            msg.dataset.state = 'error';
            msg.textContent = 'Please enter a valid email address.';
            input.focus();
            return;
          }
          input.removeAttribute('aria-invalid');
          if (local) {
            e.preventDefault();
            msg.dataset.state = 'ok';
            msg.textContent = form.dataset.success || 'Subscribed.';
            form.reset();
          }
        });
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <div className="sne-cq" data-rk-b34b0e="">
      <section className={cx(['sne', `sne--${variant}`, `sne--${size}`, className])} aria-labelledby={`${id}-title`} data-rk-b34b0e="">
        <h2 className="sne__title" id={`${id}-title`} data-rk-b34b0e="">{parts.map((p, i) => (i % 2 ? <em data-rk-b34b0e="">{p}</em> : p))}</h2>
        <div className="sne__side" data-rk-b34b0e="">
          {text && <p className="sne__text" data-rk-b34b0e="">{text}</p>}
          <form className="sne__form" action={action} method="post" noValidate data-news data-success={successText} data-rk-b34b0e="">
            <label className="sne__label" htmlFor={`${id}-email`} data-rk-b34b0e="">Email address</label>
            <div className="sne__field" data-rk-b34b0e="">
              <input id={`${id}-email`} name="email" type="email" required autoComplete="email" placeholder="you@example.com" aria-describedby={`${id}-msg`} data-rk-b34b0e="" />
              <button type="submit" data-rk-b34b0e="">{buttonLabel} <span aria-hidden="true" data-rk-b34b0e="">→</span></button>
            </div>
            <p className="sne__msg" id={`${id}-msg`} aria-live="polite" data-msg data-rk-b34b0e=""></p>
            {consent && <p className="sne__consent" data-rk-b34b0e="">{consent}</p>}
          </form>
        </div>
      </section>
      </div>
    </>
  );
}

export default ShopNewsletterEditorial;
ShopNewsletterEditorial.css
/* ShopNewsletterEditorial — ported from astro-component-kit (sections/ShopNewsletterEditorial.astro) */

.sne-cq:where([data-rk-b34b0e]) { container-type: inline-size; }
.sne:where([data-rk-b34b0e]) {
  display: grid;
  grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
  gap: clamp(1.5rem, 5vw, 5rem);
  align-items: end;
  padding: clamp(2.5rem, 6vw, 5rem) clamp(1rem, 3vw, 2.5rem);
  color: var(--c-text-1, #fff);
}
.sne--inverted:where([data-rk-b34b0e]) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); }
.sne--accent:where([data-rk-b34b0e]) { background: var(--shop-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
.sne__title:where([data-rk-b34b0e]) { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: clamp(2.6rem, 7vw, 6.5rem); font-weight: 400; letter-spacing: -0.045em; line-height: 0.92; text-wrap: balance; }
.sne--sm:where([data-rk-b34b0e]) .sne__title:where([data-rk-b34b0e]) { font-size: clamp(2rem, 5vw, 4rem); }
.sne--lg:where([data-rk-b34b0e]) .sne__title:where([data-rk-b34b0e]) { font-size: clamp(3rem, 9vw, 8.5rem); }
.sne__title:where([data-rk-b34b0e]) em:where([data-rk-b34b0e]) { font-style: italic; color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
.sne--accent:where([data-rk-b34b0e]) .sne__title:where([data-rk-b34b0e]) em:where([data-rk-b34b0e]) { color: inherit; text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 0.12em; }
.sne__text:where([data-rk-b34b0e]) { margin: 0 0 var(--sp-6, 1.5rem); max-width: 26rem; line-height: 1.65; opacity: 0.8; }
.sne__label:where([data-rk-b34b0e]) { display: block; margin-bottom: var(--sp-2, 0.5rem); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; opacity: 0.75; }
.sne__field:where([data-rk-b34b0e]) { display: flex; align-items: stretch; border-bottom: 1.5px solid currentColor; }
.sne__field:where([data-rk-b34b0e]) input:where([data-rk-b34b0e]) { flex: 1; min-width: 0; min-height: 56px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; font-size: 1.15rem; outline: none; }
.sne__field:where([data-rk-b34b0e]) input:where([data-rk-b34b0e])::placeholder { color: currentColor; opacity: 0.45; }
.sne__field:where([data-rk-b34b0e]) input[aria-invalid='true']:where([data-rk-b34b0e]) { color: var(--c-danger, #f87171); }
.sne__field:where([data-rk-b34b0e]) button:where([data-rk-b34b0e]) { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0 0 0 var(--sp-4, 1rem); border: 0; background: none; color: inherit; font: inherit; font-size: 0.85rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; }
.sne__field:where([data-rk-b34b0e]) button:hover:where([data-rk-b34b0e]) { color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
.sne--accent:where([data-rk-b34b0e]) .sne__field:where([data-rk-b34b0e]) button:hover:where([data-rk-b34b0e]) { color: inherit; text-decoration: underline; }
.sne__field:focus-within:where([data-rk-b34b0e]) { border-bottom-width: 2.5px; }
.sne__field:where([data-rk-b34b0e]) button:focus-visible:where([data-rk-b34b0e]) { outline: 2px solid currentColor; outline-offset: 2px; }
.sne__msg:where([data-rk-b34b0e]) { min-height: 1.4em; margin: var(--sp-2, 0.5rem) 0 0; font-size: 0.85rem; }
.sne__msg[data-state='error']:where([data-rk-b34b0e]) { color: var(--c-danger, #f87171); }
.sne__msg[data-state='ok']:where([data-rk-b34b0e]) { color: var(--c-success, #34d399); }
.sne--accent:where([data-rk-b34b0e]) .sne__msg:where([data-rk-b34b0e]), .sne--inverted:where([data-rk-b34b0e]) .sne__msg:where([data-rk-b34b0e]) { color: inherit; font-weight: 600; }
.sne__consent:where([data-rk-b34b0e]) { margin: var(--sp-2, 0.5rem) 0 0; font-size: 0.75rem; opacity: 0.6; }

@container (max-width: 860px) { .sne:where([data-rk-b34b0e]) { grid-template-columns: 1fr; } }