Розробка сповіщення про снження ціни для E-Commerce
Сповіщення про снження ціни — це інструмент повернення "неопределившихся" покупців. Користувач додав товар у вибране або список бажань, але не купив — можливо, ціна здавалася високою. Коли ціна знижується, автоматичний лист повертає його у лійку. Розробка займає 1–2 робочих дня, за умови що wishlist уже реалізований.
Схема даних
Підписки на снження ціни можуть бути частиною wishlist-системи або окремою сутністю:
CREATE TABLE price_drop_subscriptions (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
variant_id BIGINT REFERENCES product_variants(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
price_at_subscription NUMERIC(12,2) NOT NULL,
target_price NUMERIC(12,2), -- бажана ціна (за бажанням)
notified_at TIMESTAMP,
notified_price NUMERIC(12,2),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_pds_user_product
ON price_drop_subscriptions(user_id, product_id, COALESCE(variant_id, 0));
price_at_subscription — ціна в момент підписки. Повідомляємо при падінні щодо неї, а не щодо останньої відомої ціни.
Кнопка підписки на карточці товару
Показується поряд з кнопкою "В кошик". Для авторизованих — один клік:
const PriceDropButton = ({ product, variant }: PriceDropProps) => {
const { user } = useAuth();
const [subscribed, setSubscribed] = useState(product.is_price_drop_subscribed ?? false);
const toggle = async () => {
if (!user) {
// Редирект на логін з return_url
router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
return;
}
if (subscribed) {
await api.delete(`/price-drop-subscriptions/${product.id}`);
setSubscribed(false);
toast.success('Підписка скасована');
} else {
await api.post('/price-drop-subscriptions', {
product_id: product.id,
variant_id: variant?.id ?? null,
});
setSubscribed(true);
toast.success('Повідомимо при знженні ціни');
}
};
return (
<button
onClick={toggle}
className={cn('flex items-center gap-1.5 text-sm', {
'text-blue-600': subscribed,
'text-gray-500 hover:text-gray-700': !subscribed,
})}
>
<BellIcon className={cn('w-4 h-4', { 'fill-blue-600': subscribed })} />
{subscribed ? 'Слежу за ціною' : 'Стежити за ціною'}
</button>
);
};
API підписки
public function subscribe(Request $request): JsonResponse
{
$request->validate([
'product_id' => 'required|exists:products,id',
'variant_id' => 'nullable|exists:product_variants,id',
'target_price' => 'nullable|numeric|min:0',
]);
$product = Product::find($request->product_id);
$currentPrice = $product->sale_price ?? $product->price;
PriceDropSubscription::updateOrCreate(
[
'user_id' => $request->user()->id,
'product_id' => $request->product_id,
'variant_id' => $request->variant_id,
],
[
'email' => $request->user()->email,
'price_at_subscription' => $currentPrice,
'target_price' => $request->target_price,
'notified_at' => null,
'notified_price' => null,
]
);
return response()->json(['subscribed' => true, 'price_snapshot' => $currentPrice]);
}
Триггер при зміні ціни
Перевірка спрацьовує при оновленні ціни товару — у observer або після імпорту з ERP:
class ProductObserver
{
public function updated(Product $product): void
{
$priceChanged = $product->isDirty('price') || $product->isDirty('sale_price');
if (!$priceChanged) return;
$newPrice = $product->sale_price ?? $product->price;
$oldPrice = $product->getOriginal('sale_price') ?? $product->getOriginal('price');
if ($newPrice < $oldPrice) {
CheckPriceDropSubscriptions::dispatch($product, $newPrice)->onQueue('notifications');
}
}
}
Job відправки сповіщень
class CheckPriceDropSubscriptions implements ShouldQueue
{
public function __construct(
private Product $product,
private float $newPrice,
) {}
public function handle(): void
{
$threshold = 0.05; // повідомляємо при падінні на 5%+
PriceDropSubscription::where('product_id', $this->product->id)
->whereNull('notified_at')
->chunkById(100, function ($subscriptions) use ($threshold) {
foreach ($subscriptions as $sub) {
$dropPercent = ($sub->price_at_subscription - $this->newPrice) / $sub->price_at_subscription;
if ($dropPercent < $threshold) continue;
if ($sub->target_price && $this->newPrice > $sub->target_price) continue;
Mail::to($sub->email)->queue(new PriceDropNotification($sub, $this->product, $this->newPrice));
$sub->update([
'notified_at' => now(),
'notified_price' => $this->newPrice,
]);
}
});
}
}
chunkById — важливо для масштабованості: якщо у товару тисячі підписчиків, нельзя грузити всіх у пам'ять.
Email сповіщення про снження ціни
Лист містить:
- Фото товару, назву
- Стару ціну (зачеркнута) → нову ціну
- Відсоток снження: "Ціна знизилась на 23%"
- Кнопка "Купити сейчас" з UTM-меткою
utm_source=price_drop - Термін акції (якщо снження тимчасове)
- Посилання "Відписатися від сповіщень про ціну"
Повторні сповіщення
Після notified_at підписка вважається "використаною". Якщо ціна продовжує падати — чи повторно повідомляти або ні, визначається налаштуванням. Рекомендується: повторне сповіщення при падінні ще на 10%+ щодо notified_price.
Список "стежу за ціною" у особистому кабінеті
У розділі /account/price-tracking користувач бачить всі свої підписки: товар, ціна при підписці, поточна ціна, зміна. Це також мотивує додавати більше товарів — користувач бачить цінність сервісу.
const PriceTrackingList = ({ subscriptions }: { subscriptions: PriceDropSubscription[] }) => (
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2">Товар</th>
<th>Ціна при підписці</th>
<th>Поточна ціна</th>
<th>Зміна</th>
<th></th>
</tr>
</thead>
<tbody>
{subscriptions.map(sub => {
const change = ((sub.current_price - sub.price_at_subscription) / sub.price_at_subscription) * 100;
return (
<tr key={sub.id} className="border-b">
<td className="py-3">
<a href={`/products/${sub.product.slug}`} className="font-medium hover:underline">
{sub.product.name}
</a>
</td>
<td className="text-center">{formatPrice(sub.price_at_subscription)}</td>
<td className="text-center font-medium">{formatPrice(sub.current_price)}</td>
<td className={cn('text-center text-sm', change < 0 ? 'text-green-600' : 'text-gray-400')}>
{change < 0 ? `−${Math.abs(change).toFixed(1)}%` : `+${change.toFixed(1)}%`}
</td>
<td>
<button onClick={() => unsubscribe(sub.id)} className="text-gray-400 hover:text-red-500">
Відписатися
</button>
</td>
</tr>
);
})}
</tbody>
</table>
);







