Compare commits
20 Commits
e21cda4d6f
...
9bd448bd16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd448bd16 | ||
|
|
bca1849eee | ||
|
|
84fc88e230 | ||
|
|
d720e1888f | ||
|
|
001440a8a2 | ||
|
|
faba6f485c | ||
|
|
b922b466c9 | ||
|
|
248a01042f | ||
|
|
1364f8cb43 | ||
|
|
4034f03a8b | ||
|
|
419b617cab | ||
|
|
7dc47ee1a9 | ||
|
|
cb22faf628 | ||
|
|
f734f489cc | ||
|
|
3df74713d8 | ||
|
|
46efc7f86c | ||
|
|
e6fbc62fe9 | ||
|
|
54fb72d18c | ||
|
|
f60fbd135d | ||
|
|
22c139ad22 |
6
drizzle/0004_shocking_caretaker.sql
Normal file
6
drizzle/0004_shocking_caretaker.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE `report_locks` (
|
||||||
|
`user_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`request_id` text NOT NULL,
|
||||||
|
`started_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
966
drizzle/meta/0004_snapshot.json
Normal file
966
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,966 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "79c73a04-96a6-4849-adf6-63a502c314d0",
|
||||||
|
"prevId": "5fbf03eb-6857-4ba1-b111-819e8aabe877",
|
||||||
|
"tables": {
|
||||||
|
"account": {
|
||||||
|
"name": "account",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_user_id_user_id_fk": {
|
||||||
|
"name": "account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"chat_messages": {
|
||||||
|
"name": "chat_messages",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"client_message_id": {
|
||||||
|
"name": "client_message_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"chat_messages_client_message_id_unique": {
|
||||||
|
"name": "chat_messages_client_message_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"client_message_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"chat_user_created": {
|
||||||
|
"name": "chat_user_created",
|
||||||
|
"columns": [
|
||||||
|
"user_id",
|
||||||
|
"created_at"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"chat_messages_user_id_user_id_fk": {
|
||||||
|
"name": "chat_messages_user_id_user_id_fk",
|
||||||
|
"tableFrom": "chat_messages",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"credit_ledger": {
|
||||||
|
"name": "credit_ledger",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"delta": {
|
||||||
|
"name": "delta",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"name": "reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ref_id": {
|
||||||
|
"name": "ref_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"ledger_reason_ref": {
|
||||||
|
"name": "ledger_reason_ref",
|
||||||
|
"columns": [
|
||||||
|
"reason",
|
||||||
|
"ref_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"ledger_user": {
|
||||||
|
"name": "ledger_user",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"credit_ledger_user_id_user_id_fk": {
|
||||||
|
"name": "credit_ledger_user_id_user_id_fk",
|
||||||
|
"tableFrom": "credit_ledger",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"orders": {
|
||||||
|
"name": "orders",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"product": {
|
||||||
|
"name": "product",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"amount_kurus": {
|
||||||
|
"name": "amount_kurus",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"name": "credits",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'pending'"
|
||||||
|
},
|
||||||
|
"iyzico_token": {
|
||||||
|
"name": "iyzico_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"iyzico_form_content": {
|
||||||
|
"name": "iyzico_form_content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"iyzico_payment_id": {
|
||||||
|
"name": "iyzico_payment_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"paid_at": {
|
||||||
|
"name": "paid_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"orders_user": {
|
||||||
|
"name": "orders_user",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"orders_iyzico_token": {
|
||||||
|
"name": "orders_iyzico_token",
|
||||||
|
"columns": [
|
||||||
|
"iyzico_token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"orders_user_id_user_id_fk": {
|
||||||
|
"name": "orders_user_id_user_id_fk",
|
||||||
|
"tableFrom": "orders",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"report_locks": {
|
||||||
|
"name": "report_locks",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"request_id": {
|
||||||
|
"name": "request_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"report_locks_user_id_user_id_fk": {
|
||||||
|
"name": "report_locks_user_id_user_id_fk",
|
||||||
|
"tableFrom": "report_locks",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"name": "reports",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"name": "params",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"name": "result",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revision_count": {
|
||||||
|
"name": "revision_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"reports_user_id_unique": {
|
||||||
|
"name": "reports_user_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"reports_user_id_user_id_fk": {
|
||||||
|
"name": "reports_user_id_user_id_fk",
|
||||||
|
"tableFrom": "reports",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"saved_lists": {
|
||||||
|
"name": "saved_lists",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"name": "items",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"profil": {
|
||||||
|
"name": "profil",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"saved_lists_user_id_unique": {
|
||||||
|
"name": "saved_lists_user_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"saved_lists_user_id_user_id_fk": {
|
||||||
|
"name": "saved_lists_user_id_user_id_fk",
|
||||||
|
"tableFrom": "saved_lists",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tadimlik_havuzu": {
|
||||||
|
"name": "tadimlik_havuzu",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kova_slug": {
|
||||||
|
"name": "kova_slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tur": {
|
||||||
|
"name": "tur",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kategori_slug": {
|
||||||
|
"name": "kategori_slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tip": {
|
||||||
|
"name": "tip",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'genel'"
|
||||||
|
},
|
||||||
|
"program_id": {
|
||||||
|
"name": "program_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dilim": {
|
||||||
|
"name": "dilim",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"gerekce": {
|
||||||
|
"name": "gerekce",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"risk_notu": {
|
||||||
|
"name": "risk_notu",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"trend_ozeti": {
|
||||||
|
"name": "trend_ozeti",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"program": {
|
||||||
|
"name": "program",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ornek_sira": {
|
||||||
|
"name": "ornek_sira",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"uretim_at": {
|
||||||
|
"name": "uretim_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"tadimlik_anahtar": {
|
||||||
|
"name": "tadimlik_anahtar",
|
||||||
|
"columns": [
|
||||||
|
"kova_slug",
|
||||||
|
"tur",
|
||||||
|
"kategori_slug",
|
||||||
|
"tip"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"credit_balance": {
|
||||||
|
"name": "credit_balance",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"has_paket": {
|
||||||
|
"name": "has_paket",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"kredi_bitti_at": {
|
||||||
|
"name": "kredi_bitti_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kredi_hatirlatma_gonderildi_at": {
|
||||||
|
"name": "kredi_hatirlatma_gonderildi_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1786397951019,
|
"when": 1786397951019,
|
||||||
"tag": "0003_furry_nekra",
|
"tag": "0003_furry_nekra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790031029618,
|
||||||
|
"tag": "0004_shocking_caretaker",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -20,12 +20,18 @@ export const metadata: Metadata = {
|
|||||||
export default function ListemPage({
|
export default function ListemPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ acildi?: string; uret?: string }>;
|
// kaynak: üretim denemesini hangi yüzeyin başlattığı (bkz.
|
||||||
|
// uretim-basladi-olayi.tsx). Doğrulaması okuyan tarafta, allowlist ile.
|
||||||
|
searchParams: Promise<{ acildi?: string; uret?: string; kaynak?: string }>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<ListemIcerikSkeleton />}>
|
<Suspense fallback={<ListemIcerikSkeleton />}>
|
||||||
{searchParams.then(({ acildi, uret }) => (
|
{searchParams.then(({ acildi, uret, kaynak }) => (
|
||||||
<ListemIcerik uret={uret === "1"} acildi={acildi === "1"} />
|
<ListemIcerik
|
||||||
|
uret={uret === "1"}
|
||||||
|
acildi={acildi === "1"}
|
||||||
|
kaynak={kaynak}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default function SonucPage({
|
|||||||
sira?: string;
|
sira?: string;
|
||||||
tur?: string;
|
tur?: string;
|
||||||
sihirbaz?: string;
|
sihirbaz?: string;
|
||||||
|
kaynak?: string;
|
||||||
hazir?: string;
|
hazir?: string;
|
||||||
il?: string | string[];
|
il?: string | string[];
|
||||||
tip?: string;
|
tip?: string;
|
||||||
@@ -122,6 +123,7 @@ export default function SonucPage({
|
|||||||
sira={sira}
|
sira={sira}
|
||||||
tur={turKey}
|
tur={turKey}
|
||||||
otomatikAc={params.sihirbaz === "1"}
|
otomatikAc={params.sihirbaz === "1"}
|
||||||
|
kaynak={params.kaynak}
|
||||||
secimlerHazir={params.hazir === "1"}
|
secimlerHazir={params.hazir === "1"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (profil) {
|
if (profil) {
|
||||||
router.push(sonucHref({ sihirbaz: "1" }));
|
router.push(sonucHref({ sihirbaz: "1", kaynak: "arama" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Yapay Zeka niyetli CTA: kapı tamamlanınca /sonuc'ta üretim akışı devam
|
// Yapay Zeka niyetli CTA: kapı tamamlanınca /sonuc'ta üretim akışı devam
|
||||||
|
|||||||
@@ -3,12 +3,20 @@
|
|||||||
import { startTransition, useEffect, useState } from "react";
|
import { startTransition, useEffect, useState } from "react";
|
||||||
import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
|
import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
|
||||||
import { CalendarDays, Clock3 } from "lucide-react";
|
import { CalendarDays, Clock3 } from "lucide-react";
|
||||||
|
import {
|
||||||
const TERCIH_BASLANGICI = new Date("2026-07-29T00:00:00+03:00").getTime();
|
BANT_ARIA,
|
||||||
const TERCIH_BITISI = new Date("2026-08-10T23:59:59+03:00").getTime();
|
BANT_METNI,
|
||||||
|
SEZON_TAKVIMI,
|
||||||
|
sezonAsamasi,
|
||||||
|
sezonHedefi,
|
||||||
|
type SezonAraligi,
|
||||||
|
type SezonAsamasi,
|
||||||
|
} from "@/lib/sezon";
|
||||||
|
import { trTarih } from "@/lib/tarih";
|
||||||
|
import { TERCIH_YILI, VERI_ARALIGI } from "@/lib/veri-yillari";
|
||||||
|
|
||||||
type GeriSayim = {
|
type GeriSayim = {
|
||||||
asama: "baslamadi" | "devam-ediyor" | "sona-erdi";
|
asama: SezonAsamasi;
|
||||||
gun: number;
|
gun: number;
|
||||||
saat: number;
|
saat: number;
|
||||||
dakika: number;
|
dakika: number;
|
||||||
@@ -16,16 +24,9 @@ type GeriSayim = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function geriSayimiHesapla(simdi: number): GeriSayim {
|
function geriSayimiHesapla(simdi: number): GeriSayim {
|
||||||
const asama =
|
const asama = sezonAsamasi(simdi);
|
||||||
simdi < TERCIH_BASLANGICI
|
const hedef = sezonHedefi(asama);
|
||||||
? "baslamadi"
|
const kalan = hedef === null ? 0 : Math.max(0, hedef - simdi);
|
||||||
: simdi <= TERCIH_BITISI
|
|
||||||
? "devam-ediyor"
|
|
||||||
: "sona-erdi";
|
|
||||||
|
|
||||||
const hedef =
|
|
||||||
asama === "baslamadi" ? TERCIH_BASLANGICI : TERCIH_BITISI;
|
|
||||||
const kalan = Math.max(0, hedef - simdi);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
asama,
|
asama,
|
||||||
@@ -36,6 +37,27 @@ function geriSayimiHesapla(simdi: number): GeriSayim {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "29 Temmuz – 10 Ağustos 2026": aralık aynı yıl içindeyse yıl yalnızca sonda
|
||||||
|
* yazılır. `trTarih` bilinçli olarak `Date`/`toLocale*` kullanmaz; sunucu ile
|
||||||
|
* tarayıcı saat dilimi farklı olsa da aynı metni üretir.
|
||||||
|
*/
|
||||||
|
function araligiBicimlendir(takvim: SezonAraligi) {
|
||||||
|
const baslangicIso = takvim.baslangic.slice(0, 10);
|
||||||
|
const bitisIso = takvim.bitis.slice(0, 10);
|
||||||
|
const yil = bitisIso.slice(0, 4);
|
||||||
|
const baslangic = trTarih(baslangicIso);
|
||||||
|
|
||||||
|
return {
|
||||||
|
baslangicIso,
|
||||||
|
bitisIso,
|
||||||
|
baslangicMetni: baslangicIso.startsWith(yil)
|
||||||
|
? baslangic.replace(` ${yil}`, "")
|
||||||
|
: baslangic,
|
||||||
|
bitisMetni: trTarih(bitisIso),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const BIRIMLER = [
|
const BIRIMLER = [
|
||||||
{ anahtar: "gun", etiket: "gün" },
|
{ anahtar: "gun", etiket: "gün" },
|
||||||
{ anahtar: "saat", etiket: "sa" },
|
{ anahtar: "saat", etiket: "sa" },
|
||||||
@@ -57,36 +79,50 @@ export function SiteTopBanner() {
|
|||||||
return () => window.clearInterval(zamanlayici);
|
return () => window.clearInterval(zamanlayici);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const asamaMetni =
|
// Takvim açıklanmamışsa aşama zamandan bağımsızdır; mount öncesinde de
|
||||||
geriSayim?.asama === "baslamadi"
|
// bilinir, böylece boş sayaç bir an görünüp kaybolmaz. Takvim varsa sunucu
|
||||||
? "Başlamasına"
|
// çıktısı eskisi gibi "devam-ediyor" varsayar, ilk tikte gerçeğine döner.
|
||||||
: geriSayim?.asama === "sona-erdi"
|
const takvim = SEZON_TAKVIMI[TERCIH_YILI] ?? null;
|
||||||
? "Tercih dönemi sona erdi"
|
const asama: SezonAsamasi =
|
||||||
: "Sona ermesine";
|
geriSayim?.asama ?? (takvim ? "devam-ediyor" : "takvim-belirsiz");
|
||||||
|
const hedef = sezonHedefi(asama);
|
||||||
|
const aralik = takvim ? araligiBicimlendir(takvim) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="2026 YKS tercih dönemi"
|
aria-label={BANT_ARIA}
|
||||||
className="relative z-50 border-b border-primary-foreground/15 bg-primary text-primary-foreground print:hidden"
|
className="relative z-50 border-b border-primary-foreground/15 bg-primary text-primary-foreground print:hidden"
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex min-h-12 max-w-6xl flex-wrap items-center justify-center gap-x-5 gap-y-1.5 px-4 py-2 sm:justify-between">
|
<div className="mx-auto flex min-h-12 max-w-6xl flex-wrap items-center justify-center gap-x-5 gap-y-1.5 px-4 py-2 sm:justify-between">
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<CalendarDays className="size-4 shrink-0 opacity-80" aria-hidden />
|
<CalendarDays className="size-4 shrink-0 opacity-80" aria-hidden />
|
||||||
<p className="truncate text-sm font-semibold tracking-[-0.01em]">
|
<p className="truncate text-sm font-semibold tracking-[-0.01em]">
|
||||||
<span className="hidden sm:inline">2026 YKS tercih dönemi · </span>
|
<span className="hidden sm:inline">{BANT_ARIA} · </span>
|
||||||
<time dateTime="2026-07-29">29 Temmuz</time>
|
{aralik ? (
|
||||||
|
<>
|
||||||
|
<time dateTime={aralik.baslangicIso}>
|
||||||
|
{aralik.baslangicMetni}
|
||||||
|
</time>
|
||||||
<span aria-hidden> – </span>
|
<span aria-hidden> – </span>
|
||||||
<time dateTime="2026-08-10">10 Ağustos 2026</time>
|
<time dateTime={aralik.bitisIso}>{aralik.bitisMetni}</time>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Takvim yok: uydurma tarih yerine elimizdeki veriyi söyle.
|
||||||
|
<span>{VERI_ARALIGI} verisi</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5" role="timer">
|
<div
|
||||||
|
className="flex items-center gap-2.5"
|
||||||
|
role={hedef !== null ? "timer" : undefined}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium opacity-85">
|
<div className="flex items-center gap-1.5 text-xs font-medium opacity-85">
|
||||||
<Clock3 className="size-3.5" aria-hidden />
|
<Clock3 className="size-3.5" aria-hidden />
|
||||||
<span>{asamaMetni}</span>
|
<span>{BANT_METNI[asama]}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{geriSayim?.asama !== "sona-erdi" ? (
|
{hedef !== null ? (
|
||||||
<dl
|
<dl
|
||||||
className="flex items-baseline gap-1 font-mono text-xs tabular-nums"
|
className="flex items-baseline gap-1 font-mono text-xs tabular-nums"
|
||||||
aria-label={
|
aria-label={
|
||||||
|
|||||||
52
src/features/kullanici/components/giris-linki.tsx
Normal file
52
src/features/kullanici/components/giris-linki.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navbar'ın "Giriş yap" butonu: giriş bitince kullanıcıyı **bulunduğu
|
||||||
|
* sayfaya** geri döndürür (`/giris?callback=<yol>`). Callback verilmezse
|
||||||
|
* `giris-karti.tsx` "/"'a düşürüyordu — rehber okuyan ziyaretçi girişten
|
||||||
|
* sonra ana sayfada uyanıyordu.
|
||||||
|
*
|
||||||
|
* Callback neden sabit bir hedef (ör. `/listem`) değil: buton her sayfada
|
||||||
|
* duruyor, sabit hedef bağlam kaybı olurdu; `/listem?uret=1` yazılsaydı
|
||||||
|
* girişten dönüşte üretim kendiliğinden başlar ve kullanıcının ayrı onayı
|
||||||
|
* olmadan 3 kredi yakardı.
|
||||||
|
*
|
||||||
|
* Callback yalnız pathname taşır: sorgu dizesini okuyan kanca tüm navbar'ı
|
||||||
|
* Suspense zorunluluğuna sokardı. `usePathname` bu ağaçta zaten var
|
||||||
|
* (`header-gate`), `UserNav` da site-header'da Suspense içinde render
|
||||||
|
* ediliyor — cacheComponents açıkken dinamik parametreli rotalarda gereken
|
||||||
|
* sınır hazır (next/dist/docs/.../use-pathname.md).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* better-auth (1.6.23) callback'i kendi göreli-yol kalkanından geçiriyor:
|
||||||
|
* `^\/(?!\/|\\|%2f|%5c)[\w\-.+/@]*(?:\?…)?$`. `\w` ASCII olduğu için
|
||||||
|
* `/rehber/ösym` ya da yüzde-kodlanmış `/bolum/t%C4%B1p` gibi yollar
|
||||||
|
* **403 INVALID_CALLBACK_URL** ile reddediliyor ve kullanıcı o sayfadan hiç
|
||||||
|
* giriş yapamıyor (main'de callback hep "/" olduğu için bu yol yoktu).
|
||||||
|
* Bu yüzden callback'i ancak auth katmanının da kabul edeceği yollarda
|
||||||
|
* takıyoruz; gerisinde sade /giris'e düşüyoruz — bağlam kaybı, kırık giriş
|
||||||
|
* akışına yeğdir. Kalkan daraltılırsa (tek yer) buradaki de gevşetilebilir.
|
||||||
|
*/
|
||||||
|
const AUTH_GUVENLI_YOL = /^\/(?!\/|\\)[\w\-./+@]*$/;
|
||||||
|
|
||||||
|
export function GirisLinki() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
// /giris kendine callback veremez (döngü); pathname yoksa sade hedef.
|
||||||
|
const href =
|
||||||
|
pathname && !pathname.startsWith("/giris") && AUTH_GUVENLI_YOL.test(pathname)
|
||||||
|
? `/giris?callback=${encodeURIComponent(pathname)}`
|
||||||
|
: "/giris";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="h-9 cursor-pointer rounded-full bg-slate-900 px-3.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-slate-700 sm:h-12 sm:px-7 sm:text-base"
|
||||||
|
>
|
||||||
|
<Link href={href}>Giriş yap</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Coins } from "lucide-react";
|
import { ClipboardList, Coins } from "lucide-react";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ListemButonu } from "@/features/liste/components/listem-butonu";
|
import { ListemButonu } from "@/features/liste/components/listem-butonu";
|
||||||
import { ListeSenkron } from "@/features/liste/components/liste-senkron";
|
import { ListeSenkron } from "@/features/liste/components/liste-senkron";
|
||||||
import { RybbitIdentify } from "@/components/rybbit-identify";
|
import { RybbitIdentify } from "@/components/rybbit-identify";
|
||||||
|
import { GirisLinki } from "./giris-linki";
|
||||||
import { SignOutButton } from "./sign-out-button";
|
import { SignOutButton } from "./sign-out-button";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,12 +32,7 @@ export async function UserNav() {
|
|||||||
<RybbitIdentify kullaniciId={null} />
|
<RybbitIdentify kullaniciId={null} />
|
||||||
<ListeSenkron userId={null} />
|
<ListeSenkron userId={null} />
|
||||||
<ListemButonu />
|
<ListemButonu />
|
||||||
<Button
|
<GirisLinki />
|
||||||
asChild
|
|
||||||
className="h-9 cursor-pointer rounded-full bg-slate-900 px-3.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-slate-700 sm:h-12 sm:px-7 sm:text-base"
|
|
||||||
>
|
|
||||||
<Link href="/giris">Giriş yap</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,6 +42,24 @@ export async function UserNav() {
|
|||||||
<RybbitIdentify kullaniciId={u.id} eposta={u.email} isim={u.name} />
|
<RybbitIdentify kullaniciId={u.id} eposta={u.email} isim={u.name} />
|
||||||
<ListeSenkron userId={u.id} />
|
<ListeSenkron userId={u.id} />
|
||||||
<ListemButonu />
|
<ListemButonu />
|
||||||
|
{/* Yapay Zeka raporunun kalıcı kapısı: girişli kullanıcı /listem'i
|
||||||
|
navbar'dan bulamıyordu (çekmece butonu manuel liste boşken hiç
|
||||||
|
görünmüyor). Rapor var mı diye DB'ye sorulmuyor — navbar'ın TTFB'si
|
||||||
|
bozulmasın; liste boşsa sayfadaki ListemBosCta karşılıyor.
|
||||||
|
Sınıf seti kredi pill'iyle birebir aynı (tek kaynak). */}
|
||||||
|
<Link
|
||||||
|
href="/listem"
|
||||||
|
aria-label="Yapay Zeka Listem"
|
||||||
|
title="Yapay Zeka'nın kurduğu 24 tercihlik listen"
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 sm:h-12 sm:gap-2 sm:px-5 sm:text-sm"
|
||||||
|
>
|
||||||
|
<ClipboardList className="size-3.5 text-primary sm:size-4" aria-hidden />
|
||||||
|
{/* ListemButonu:39 ile aynı görünürlük deseni: 375 px'te ve lg–xl
|
||||||
|
arasında metin gizli, ikon+aria-label anlamı taşıyor. */}
|
||||||
|
<span className="hidden sm:inline lg:hidden xl:inline">
|
||||||
|
Yapay Zeka Listem
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/paket?kaynak=nav"
|
href="/paket?kaynak=nav"
|
||||||
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 sm:h-12 sm:gap-2 sm:px-5 sm:text-sm"
|
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 sm:h-12 sm:gap-2 sm:px-5 sm:text-sm"
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export function ListeCekmecesi() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="mt-1 truncate text-sm leading-relaxed text-white">
|
<p className="mt-1 truncate text-sm leading-relaxed text-white">
|
||||||
{riskEksik
|
{riskEksik
|
||||||
? "Sıralamanı girince her seçimin riskini gösteririz."
|
? "Riskleri gösterelim; istersen 24'lük listeni de kur (3 kredi)."
|
||||||
: "Her seçimin yerleşme riski hazır — son kararı sen ver."}
|
: "Her seçimin yerleşme riski hazır — son kararı sen ver."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,7 +290,10 @@ export function ListeCekmecesi() {
|
|||||||
className="shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
className="shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
cekmeceKapat();
|
cekmeceKapat();
|
||||||
profilToplamaAc(null, true, "arti_toast");
|
// Hedef "sihirbaz": kapı kapanınca akış /sonuc'ta devam
|
||||||
|
// etsin (girişliyse üretim, girişsizse ücretsiz tablo) —
|
||||||
|
// kullanıcı çekmecenin kapandığı boşlukta kalmasın.
|
||||||
|
profilToplamaAc("sihirbaz", true, "arti_toast");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sıralamamı gir
|
Sıralamamı gir
|
||||||
|
|||||||
@@ -69,8 +69,12 @@ export function ProgramEkleButonu({
|
|||||||
toast.success("Seçtiklerine eklendi", {
|
toast.success("Seçtiklerine eklendi", {
|
||||||
id: PROFILSIZ_EKLEME_TOAST_ID,
|
id: PROFILSIZ_EKLEME_TOAST_ID,
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
|
// Vaat, akışın gerçek sonucuna eşit olmalı: bu kapı artık yalnız
|
||||||
|
// risk rozetiyle bitmiyor, girişli kullanıcıda 3 kredilik üretime
|
||||||
|
// kadar gidiyor (bkz. hedef "sihirbaz"). Bedeli kapının girişinde
|
||||||
|
// söylüyoruz; son butonda da "(3 kredi)" ikinci kez görünüyor.
|
||||||
description:
|
description:
|
||||||
"Sıralamanı girersen bu programın risk seviyesini de gösteririz.",
|
"Sıralamanı girersen bu programın risk seviyesini gösteririz; birkaç seçimle 24'lük listeni de kurabilirsin (3 kredi).",
|
||||||
action: {
|
action: {
|
||||||
label: (
|
label: (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
@@ -78,8 +82,10 @@ export function ProgramEkleButonu({
|
|||||||
<ArrowRight className="size-4" aria-hidden />
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
// Tanıtım ekranını atla, doğrudan sıralama adımı
|
// Tanıtım ekranını atla, doğrudan sıralama adımı. Hedef
|
||||||
onClick: () => profilToplamaAc(null, true, "arti_toast"),
|
// "sihirbaz": kapı kapanınca kullanıcı yerinde kalmasın, akış
|
||||||
|
// /sonuc'ta devam etsin (girişliyse üretim, girişsizse tablo).
|
||||||
|
onClick: () => profilToplamaAc("sihirbaz", true, "arti_toast"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export type KapiTetik =
|
|||||||
| "arti_toast"
|
| "arti_toast"
|
||||||
| "sonuc"
|
| "sonuc"
|
||||||
| "sihirbaz"
|
| "sihirbaz"
|
||||||
|
// Girişli ama listesiz kullanıcının /listem boş ekranındaki CTA'sı. Ayrı
|
||||||
|
// değer, çünkü bu kapı huninin en sonunda açılıyor: "giriş yaptı ama profili
|
||||||
|
// yok" dilimini arama kartı gibi genel yüzeylerden ayırt edebilmek gerek.
|
||||||
|
| "listem_bos"
|
||||||
| "duzenle"
|
| "duzenle"
|
||||||
| "diger";
|
| "diger";
|
||||||
|
|
||||||
@@ -294,11 +298,24 @@ export function programEkleIste(opts: {
|
|||||||
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner.
|
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner.
|
||||||
* `girisli`, "sihirbaz" hedefinde üretimin otomatik başlayıp başlamayacağını
|
* `girisli`, "sihirbaz" hedefinde üretimin otomatik başlayıp başlamayacağını
|
||||||
* belirler (kapı bileşeni oturumu facet isteğiyle paralel çözer). */
|
* belirler (kapı bileşeni oturumu facet isteğiyle paralel çözer). */
|
||||||
|
/** Kapı tetiği → üretim denemesinin ölçüm etiketi. Yalnız `hedef="sihirbaz"`
|
||||||
|
* dalında kullanılır; listede olmayan tetik `bilinmiyor` sayılır (izinli
|
||||||
|
* değerlerin tek kaynağı: rapor/components/uretim-basladi-olayi.tsx). */
|
||||||
|
const KAYNAK_TETIKTEN: Partial<Record<KapiTetik, string>> = {
|
||||||
|
arti: "arti",
|
||||||
|
arti_toast: "arti",
|
||||||
|
listem_bos: "bos_durum",
|
||||||
|
sihirbaz: "sihirbaz",
|
||||||
|
sonuc: "sonuc_kapi",
|
||||||
|
};
|
||||||
|
|
||||||
export function profilTamamlandi(
|
export function profilTamamlandi(
|
||||||
profil: TercihProfili,
|
profil: TercihProfili,
|
||||||
girisli = false,
|
girisli = false,
|
||||||
): string | null {
|
): string | null {
|
||||||
const duzenle = durum.duzenle;
|
const duzenle = durum.duzenle;
|
||||||
|
// durum aşağıda sıfırlanıyor (tetik: "diger"); ölçüm etiketi için önce yakala
|
||||||
|
const tetik = durum.tetik;
|
||||||
riskleriYenile(profil);
|
riskleriYenile(profil);
|
||||||
const bekleyen = durum.bekleyen;
|
const bekleyen = durum.bekleyen;
|
||||||
const hedef = durum.bekleyenHedef;
|
const hedef = durum.bekleyenHedef;
|
||||||
@@ -328,7 +345,15 @@ export function profilTamamlandi(
|
|||||||
// Yapay Zeka niyetli giriş: kapıda sihirbazı zaten doldurdu, /sonuc'ta
|
// Yapay Zeka niyetli giriş: kapıda sihirbazı zaten doldurdu, /sonuc'ta
|
||||||
// akış kesilmesin — girişliyse üretim otomatik başlar (?sihirbaz=1),
|
// akış kesilmesin — girişliyse üretim otomatik başlar (?sihirbaz=1),
|
||||||
// girişsizse ücretsiz tablo + giriş CTA paneli (?hazir=1) karşılar.
|
// girişsizse ücretsiz tablo + giriş CTA paneli (?hazir=1) karşılar.
|
||||||
return sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" });
|
//
|
||||||
|
// `kaynak`: girişli dalda /sonuc üretimi kendiliğinden başlatıyor, yani
|
||||||
|
// denemeyi başlatan yüzey bu kapıdır. Tetikten türetiliyor ki hero ya da
|
||||||
|
// arama kartıyla aynı kovaya düşmesin (bkz. uretim-basladi-olayi.tsx).
|
||||||
|
return sonucHref(
|
||||||
|
girisli
|
||||||
|
? { sihirbaz: "1", kaynak: KAYNAK_TETIKTEN[tetik] ?? "bilinmiyor" }
|
||||||
|
: { hazir: "1" },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (hedef === "sonuc") return sonucHref();
|
if (hedef === "sonuc") return sonucHref();
|
||||||
// Düzenleme /sonuc'a götürür: tablo profil çerezinden SSR'lanır
|
// Düzenleme /sonuc'a götürür: tablo profil çerezinden SSR'lanır
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ async function PaketCta() {
|
|||||||
{/* ?uret=1: rapor varsa /listem raporu gösterir (bayrak yok
|
{/* ?uret=1: rapor varsa /listem raporu gösterir (bayrak yok
|
||||||
sayılır), yoksa bekleyen seçimlerle üretim başlar; seçim
|
sayılır), yoksa bekleyen seçimlerle üretim başlar; seçim
|
||||||
de yoksa boş durum sihirbaza yönlendirir. */}
|
de yoksa boş durum sihirbaza yönlendirir. */}
|
||||||
<Link href="/listem?uret=1" className="underline">
|
<Link href="/listem?uret=1&kaynak=odeme_sonrasi" className="underline">
|
||||||
listeni oluştur
|
listeni oluştur
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function RaporYokCta() {
|
|||||||
asChild
|
asChild
|
||||||
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
>
|
>
|
||||||
<Link href={profil ? sonucHref({ sihirbaz: "1" }) : "/"}>
|
<Link href={profil ? sonucHref({ sihirbaz: "1", kaynak: "rapor_yok" }) : "/"}>
|
||||||
24'lük listeni oluştur
|
24'lük listeni oluştur
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function HeroFocusButton() {
|
|||||||
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
>
|
>
|
||||||
Ücretsiz dene
|
Ücretsiz dene
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,8 +42,7 @@ export function KapanisCta() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
|
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
|
||||||
Sıralaman kayıtlı — yapay zekâ danışmanın veriyle konuşsun. Tercih
|
Sıralaman kayıtlı — yapay zekâ danışmanın veriyle konuşsun.
|
||||||
dönemi bitmeden yerini al.
|
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
@@ -61,8 +61,7 @@ export function KapanisCta() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
|
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
|
||||||
Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun. Tercih dönemi
|
Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun.
|
||||||
bitmeden yerini al.
|
|
||||||
</p>
|
</p>
|
||||||
<HeroFocusButton />
|
<HeroFocusButton />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -44,22 +44,33 @@ const GECIS_SINIRI_MS = 6000;
|
|||||||
// kesilmez, cevap gelirse akış normal devam eder.
|
// kesilmez, cevap gelirse akış normal devam eder.
|
||||||
const UZUN_SURE_MS = 90_000;
|
const UZUN_SURE_MS = 90_000;
|
||||||
|
|
||||||
|
const SAGLIK_YOKLAMA_MS = 5000;
|
||||||
|
|
||||||
/** Sunucu ayakta mı (bkz. app/api/saglik). Ağ hatası ve 5xx "hayır" sayılır.
|
/** Sunucu ayakta mı (bkz. app/api/saglik). Ağ hatası ve 5xx "hayır" sayılır.
|
||||||
* AbortSignal.timeout Safari 16'da geldi; eski iPad'lerde yokluğu yoklamayı
|
* AbortSignal.timeout Safari 16'da geldi; eski iPad'lerde yokluğu yoklamayı
|
||||||
* "sunucu ölü" sanmaya çevirmesin diye varlığı denetlenir. */
|
* "sunucu ölü" sanmaya çevirmesin diye varlığı denetlenir — ama o cihazlarda
|
||||||
|
* istek süresiz asılabilir ve yoklama artık MUTLU YOLDA (gezinmeden önce)
|
||||||
|
* koştuğu için asılması her başarılı üretimi kilitlerdi; zamanaşımı bu yüzden
|
||||||
|
* yarışla da kurulur. Kaybeden fetch'in sonradan reddetmesi `catch` ile
|
||||||
|
* yutulur, aksi hâlde sahipsiz reddetme olurdu. */
|
||||||
async function sunucuAyaktaMi(): Promise<boolean> {
|
async function sunucuAyaktaMi(): Promise<boolean> {
|
||||||
try {
|
const zamanasimiDestekli = typeof AbortSignal.timeout === "function";
|
||||||
const yanit = await fetch("/api/saglik", {
|
const istek = fetch("/api/saglik", {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
signal:
|
signal: zamanasimiDestekli
|
||||||
typeof AbortSignal.timeout === "function"
|
? AbortSignal.timeout(SAGLIK_YOKLAMA_MS)
|
||||||
? AbortSignal.timeout(5000)
|
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
})
|
||||||
return yanit.ok;
|
.then((yanit) => yanit.ok)
|
||||||
} catch {
|
.catch(() => false);
|
||||||
return false;
|
|
||||||
}
|
if (zamanasimiDestekli) return istek;
|
||||||
|
return Promise.race([
|
||||||
|
istek,
|
||||||
|
new Promise<boolean>((coz) =>
|
||||||
|
setTimeout(() => coz(false), SAGLIK_YOKLAMA_MS),
|
||||||
|
),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Durum =
|
type Durum =
|
||||||
@@ -70,6 +81,8 @@ type Durum =
|
|||||||
| { ad: "geciliyor" }
|
| { ad: "geciliyor" }
|
||||||
// Liste kuruldu ve kaydedildi ama sunucu yanıt vermiyor (ters vekil 502)
|
// Liste kuruldu ve kaydedildi ama sunucu yanıt vermiyor (ters vekil 502)
|
||||||
| { ad: "sunucu-yok" }
|
| { ad: "sunucu-yok" }
|
||||||
|
// Aynı kullanıcı için üretim başka bir sekmede/denemede zaten koşuyor
|
||||||
|
| { ad: "suruyor" }
|
||||||
| { ad: "kredi-yetersiz" }
|
| { ad: "kredi-yetersiz" }
|
||||||
| { ad: "hata"; mesaj: string }
|
| { ad: "hata"; mesaj: string }
|
||||||
| { ad: "profil-yok" };
|
| { ad: "profil-yok" };
|
||||||
@@ -128,6 +141,27 @@ export function ListeUretici({
|
|||||||
tur: profil.tur,
|
tur: profil.tur,
|
||||||
sira_kovasi: siraKovasi(profil.sira),
|
sira_kovasi: siraKovasi(profil.sira),
|
||||||
});
|
});
|
||||||
|
// BİRİNCİL YOL KORUMASI (22 Eyl 2026). Emniyet supabındaki yoklama tek
|
||||||
|
// başına yetmiyor: router.replace'in RSC isteği ölü sunucuda 502'ye
|
||||||
|
// düşerse tam-sayfa gezinmeyi Next'in KENDİSİ yapıyor ve supap hiç
|
||||||
|
// çalışmıyor. Zincir (next 16.2.10):
|
||||||
|
// fetch-server-response.js — cevap flight değilse ya da !res.ok ise
|
||||||
|
// doMpaNavigation(url) → string döner
|
||||||
|
// segment-cache/navigation.js — typeof result === "string" →
|
||||||
|
// completeHardNavigation → pushRef.mpaNavigation = true
|
||||||
|
// app-router.js — mpaNavigation'da location.replace(canonicalUrl)
|
||||||
|
// Sonuç ters vekilin düz metin "Bad Gateway" gövdesinin tarayıcıya
|
||||||
|
// inmesi, WebKit'te de listem.txt olarak indirilmesiydi.
|
||||||
|
//
|
||||||
|
// Yoklama BİLEREK "geciliyor"dan önce: o durum 6 sn'lik supap
|
||||||
|
// zamanlayıcısını kuruyor, yoklamayı sonrasına koymak iki yolu
|
||||||
|
// yarıştırıp aynı ekranı iki kez yazdırırdı. Üretim ekranı birkaç
|
||||||
|
// milisaniye daha durur, kullanıcı için fark yok.
|
||||||
|
if (!(await sunucuAyaktaMi())) {
|
||||||
|
olay("liste_uretim_hata", { kod: "SUNUCUYA_ULASILAMIYOR" });
|
||||||
|
setDurum({ ad: "sunucu-yok" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDurum({ ad: "geciliyor" });
|
setDurum({ ad: "geciliyor" });
|
||||||
// Next'in kendi gezinmesi: URL (?uret=1 düşer) ve sayfa ağacı birlikte
|
// Next'in kendi gezinmesi: URL (?uret=1 düşer) ve sayfa ağacı birlikte
|
||||||
// güncellenir. Burada ikinci bir yönlendirici aksiyonu (refresh) YOK —
|
// güncellenir. Burada ikinci bir yönlendirici aksiyonu (refresh) YOK —
|
||||||
@@ -142,6 +176,13 @@ export function ListeUretici({
|
|||||||
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
|
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
|
||||||
olay("kredi_bitti");
|
olay("kredi_bitti");
|
||||||
setDurum({ ad: "kredi-yetersiz" });
|
setDurum({ ad: "kredi-yetersiz" });
|
||||||
|
} else if (sonuc.code === "SURUYOR") {
|
||||||
|
// Kredi HARCANMADI: sunucu kilidi krediden önce kontrol ediyor. Bu dalda
|
||||||
|
// yeniden denetmiyoruz — "Tekrar dene" taze bir requestId üretir ve
|
||||||
|
// kilit düştüğü anda ikinci bir üretim başlatır, yani tam kaçındığımız
|
||||||
|
// çifte harcama. Kullanıcıyı listeye yönlendiriyoruz; asıl üretim orada.
|
||||||
|
olay("liste_uretim_hata", { kod: "SURUYOR" });
|
||||||
|
setDurum({ ad: "suruyor" });
|
||||||
} else if (sonuc.code === "AUTH") {
|
} else if (sonuc.code === "AUTH") {
|
||||||
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
|
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -249,6 +290,8 @@ export function ListeUretici({
|
|||||||
|
|
||||||
if (durum.ad === "sunucu-yok") return <SunucuYokEkrani />;
|
if (durum.ad === "sunucu-yok") return <SunucuYokEkrani />;
|
||||||
|
|
||||||
|
if (durum.ad === "suruyor") return <SuruyorEkrani />;
|
||||||
|
|
||||||
if (durum.ad === "hata") {
|
if (durum.ad === "hata") {
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
<section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||||
@@ -327,6 +370,31 @@ function SunucuYokEkrani() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Üretim başka bir sekmede/denemede sürüyor. Kredi burada HARCANMADI (kilit
|
||||||
|
* kredi harcamasından önce alınıyor, bkz. lib/uretim-kilidi.ts). Bilerek
|
||||||
|
* "Tekrar dene" yok: tekrar taze requestId üretip ikinci üretimi başlatırdı. */
|
||||||
|
function SuruyorEkrani() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||||
|
<h1 className="font-heading text-xl font-bold">Listen zaten hazırlanıyor</h1>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
|
||||||
|
Bunu başka bir sekmede başlatmışsın. İki kez kurmayalım — kredin bu
|
||||||
|
denemede harcanmadı. Hazır olunca listende görünecek.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<Link href="/listem">
|
||||||
|
Listeme git
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
|
function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
|
||||||
const mesaj = useUretimMesaji();
|
const mesaj = useUretimMesaji();
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,20 @@ import Link from "next/link";
|
|||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
import {
|
||||||
|
profilToplamaAc,
|
||||||
|
useTercihProfili,
|
||||||
|
} from "@/features/liste/hooks/use-tercih-profili";
|
||||||
|
|
||||||
|
// Her iki dalın butonu da aynı CTA: sınıf seti tek yerde dursun.
|
||||||
|
const CTA_SINIF = "mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /listem boş hâlinin metni + CTA'sı: seçimleri zaten hazır olan adayı
|
* /listem boş hâlinin metni + CTA'sı: seçimleri zaten hazır olan adayı
|
||||||
* /sonuc üzerinden dolaştırmadan doğrudan üretime (?uret=1) alır; profil
|
* /sonuc üzerinden dolaştırmadan doğrudan üretime (?uret=1) alır; profil
|
||||||
* yoksa ana sayfadaki sıralama formuna yönlendirir.
|
* yoksa kullanıcıyı ana sayfaya atmadan tercih kapısını **burada** açar.
|
||||||
|
* Kapı bitince `profilTamamlandi` "sihirbaz" hedefini /sonuc?sihirbaz=1'e
|
||||||
|
* çevirir, oradan /listem?uret=1'e dönülür — ayrı bir dal yok.
|
||||||
*/
|
*/
|
||||||
export function ListemBosCta() {
|
export function ListemBosCta() {
|
||||||
const profil = useTercihProfili();
|
const profil = useTercihProfili();
|
||||||
@@ -22,16 +30,24 @@ export function ListemBosCta() {
|
|||||||
: "Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip Yapay Zeka danışmanla üzerinde konuşabilirsin."}
|
: "Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip Yapay Zeka danışmanla üzerinde konuşabilirsin."}
|
||||||
</p>
|
</p>
|
||||||
<PagePixelDivider seed={67} className="mx-auto mt-6" />
|
<PagePixelDivider seed={67} className="mx-auto mt-6" />
|
||||||
<Button
|
{profil ? (
|
||||||
asChild
|
<Button asChild size="lg" className={CTA_SINIF}>
|
||||||
size="lg"
|
<Link href="/listem?uret=1&kaynak=bos_durum">
|
||||||
className="mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
Yapay Zeka listeni kur
|
||||||
>
|
|
||||||
<Link href={profil ? "/listem?uret=1" : "/"}>
|
|
||||||
{profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"}
|
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
className={CTA_SINIF}
|
||||||
|
onClick={() => profilToplamaAc("sihirbaz", true, "listem_bos")}
|
||||||
|
>
|
||||||
|
Sıralamanı gir, Yapay Zeka listeni kur
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { Mesaj } from "./sohbet-client";
|
|||||||
import { ListemGovde } from "./listem-govde";
|
import { ListemGovde } from "./listem-govde";
|
||||||
import { ListemBosCta } from "./listem-bos-cta";
|
import { ListemBosCta } from "./listem-bos-cta";
|
||||||
import { ListeUretici } from "./liste-uretici";
|
import { ListeUretici } from "./liste-uretici";
|
||||||
|
import { UretimBasladiOlayi } from "./uretim-basladi-olayi";
|
||||||
|
|
||||||
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
@@ -28,9 +29,13 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
|||||||
export async function ListemIcerik({
|
export async function ListemIcerik({
|
||||||
uret,
|
uret,
|
||||||
acildi,
|
acildi,
|
||||||
|
kaynak,
|
||||||
}: {
|
}: {
|
||||||
uret: boolean;
|
uret: boolean;
|
||||||
acildi: boolean;
|
acildi: boolean;
|
||||||
|
/** Üretim denemesini başlatan yüzey (`?kaynak=`); doğrulaması
|
||||||
|
* UretimBasladiOlayi'nın allowlist'inde. */
|
||||||
|
kaynak?: string;
|
||||||
}) {
|
}) {
|
||||||
// Tercihleri girili olan (profil çerezi duran) ziyaretçi girişten dönünce
|
// Tercihleri girili olan (profil çerezi duran) ziyaretçi girişten dönünce
|
||||||
// "Henüz listen yok" boşluğuyla karşılaşmasın: callback ?uret=1 taşır,
|
// "Henüz listen yok" boşluğuyla karşılaşmasın: callback ?uret=1 taşır,
|
||||||
@@ -39,7 +44,14 @@ export async function ListemIcerik({
|
|||||||
const cerezProfili = cerezdenTercihProfili(
|
const cerezProfili = cerezdenTercihProfili(
|
||||||
(await cookies()).get(PROFIL_CEREZ_ADI)?.value,
|
(await cookies()).get(PROFIL_CEREZ_ADI)?.value,
|
||||||
);
|
);
|
||||||
const geriYol = uret || cerezProfili ? "/listem?uret=1" : "/listem";
|
// `kaynak` giriş round-trip'inde korunur; yoksa girişten dönen herkes
|
||||||
|
// "bilinmiyor" okunurdu. `uret=1` HER ZAMAN ilk parametre kalmalı:
|
||||||
|
// giris-karti.tsx:31 funnel metnini `startsWith("/listem?uret=1")` ile
|
||||||
|
// seçiyor, sıra bozulursa "Seçimlerin hazır" metni kaybolur.
|
||||||
|
const geriYol =
|
||||||
|
uret || cerezProfili
|
||||||
|
? `/listem?uret=1${kaynak ? `&kaynak=${encodeURIComponent(kaynak)}` : ""}`
|
||||||
|
: "/listem";
|
||||||
const session = await verifySession(geriYol);
|
const session = await verifySession(geriYol);
|
||||||
const [user, satir, sohbetGecmisi] = await Promise.all([
|
const [user, satir, sohbetGecmisi] = await Promise.all([
|
||||||
getCurrentUser(),
|
getCurrentUser(),
|
||||||
@@ -71,11 +83,20 @@ export async function ListemIcerik({
|
|||||||
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim
|
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim
|
||||||
burada koşar, animasyon ve hata/kredi durumları burada yaşar. */}
|
burada koşar, animasyon ve hata/kredi durumları burada yaşar. */}
|
||||||
{uret ? (
|
{uret ? (
|
||||||
|
<>
|
||||||
|
{/* Denemenin paydası: üretim gövdesiyle kardeş, aynı koşulda
|
||||||
|
mount olur (bkz. uretim-basladi-olayi.tsx). */}
|
||||||
|
<UretimBasladiOlayi
|
||||||
|
kaynak={kaynak ?? "bilinmiyor"}
|
||||||
|
paketli={Boolean(user.hasPaket)}
|
||||||
|
sunucuProfil={cerezProfili}
|
||||||
|
/>
|
||||||
<ListeUretici
|
<ListeUretici
|
||||||
hasPaket={Boolean(user.hasPaket)}
|
hasPaket={Boolean(user.hasPaket)}
|
||||||
sunucuProfil={cerezProfili}
|
sunucuProfil={cerezProfili}
|
||||||
bosDurum={bosDurum}
|
bosDurum={bosDurum}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
bosDurum
|
bosDurum
|
||||||
)}
|
)}
|
||||||
|
|||||||
79
src/features/rapor/components/uretim-basladi-olayi.tsx
Normal file
79
src/features/rapor/components/uretim-basladi-olayi.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Üretim denemesinin paydasını ölçen görünmez bileşen: /listem?uret=1 ile
|
||||||
|
// üretim gövdesi kurulduğunda `liste_uretim_basladi` atar. Bugüne kadar
|
||||||
|
// yalnız başarı (`liste_olusturuldu`) sayıldığı için "kaç kişi denedi, kaçı
|
||||||
|
// listeye ulaştı" sorusunun paydası yoktu.
|
||||||
|
//
|
||||||
|
// Olay bilerek üretimi başlatan `liste-uretici.tsx`'te değil, onun kardeşi
|
||||||
|
// olan bu dosyada duruyor (o dosya bu turda başka bir işin altında). İkisi de
|
||||||
|
// `listem-icerik.tsx`'te aynı `uret` dalında mount edildiğinden koşulları
|
||||||
|
// birebir aynı: biri mount oluyorsa diğeri de oluyor.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { olay } from "@/lib/analitik";
|
||||||
|
import type { TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
|
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `?kaynak=` sözleşmesinin izinli değerleri. Değer URL'den, yani kullanıcının
|
||||||
|
* denetiminden geliyor: allowlist olmadan Rybbit'e keyfi dizgi yazılabilir
|
||||||
|
* (kardinalite patlaması, panele enjekte edilen metin). Listede olmayan her
|
||||||
|
* şey `"bilinmiyor"`a düşer. Yeni bir üretim yüzeyi eklerken değerini önce
|
||||||
|
* buraya ekle, sonra href'e koy.
|
||||||
|
*/
|
||||||
|
const IZINLI_KAYNAKLAR = new Set([
|
||||||
|
"sihirbaz",
|
||||||
|
"hazir_cta",
|
||||||
|
// Gerçek giriş dönüşü: /giris callback'i doğrudan /listem'e iner.
|
||||||
|
"giris_donusu",
|
||||||
|
"bos_durum",
|
||||||
|
"odeme_sonrasi",
|
||||||
|
// /sonuc'ta ?sihirbaz=1 ile otomatik başlayan üretimin gerçek yüzeyleri.
|
||||||
|
// Eskiden hepsi "giris_donusu" sayılıyordu (güvenlik denetimi G3).
|
||||||
|
"hero",
|
||||||
|
"cta",
|
||||||
|
"arama",
|
||||||
|
"arti",
|
||||||
|
"sonuc_kapi",
|
||||||
|
"rapor_yok",
|
||||||
|
"bilinmiyor",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function UretimBasladiOlayi({
|
||||||
|
kaynak,
|
||||||
|
paketli,
|
||||||
|
sunucuProfil,
|
||||||
|
}: {
|
||||||
|
/** `?kaynak=` ham değeri; allowlist dışındaysa "bilinmiyor" raporlanır. */
|
||||||
|
kaynak: string;
|
||||||
|
paketli: boolean;
|
||||||
|
/** Profil çerezinden SSR'da çözülen yedek profil (bkz. listem-icerik). */
|
||||||
|
sunucuProfil: TercihProfili | null;
|
||||||
|
}) {
|
||||||
|
// Mount başına tek atış. StrictMode geliştirmede effect'i iki kez koşuyor;
|
||||||
|
// `liste-uretici.tsx`'teki `basladi` ref'iyle aynı desen. `olayBirKez`
|
||||||
|
// BİLEREK kullanılmıyor: o oturum başına tekilleştirir, oysa aynı oturumda
|
||||||
|
// ikinci deneme de paydaya girmeli.
|
||||||
|
const atildi = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (atildi.current) return;
|
||||||
|
atildi.current = true;
|
||||||
|
const kaynakGuvenli = IZINLI_KAYNAKLAR.has(kaynak) ? kaynak : "bilinmiyor";
|
||||||
|
olay("liste_uretim_basladi", {
|
||||||
|
kaynak: kaynakGuvenli,
|
||||||
|
paketli: paketli ? "evet" : "hayir",
|
||||||
|
// Üretimin gerçekten başlayabilmesinin koşulu: bu ifade
|
||||||
|
// `liste-uretici.tsx`'teki `tercihProfiliOku() ?? sunucuProfil`
|
||||||
|
// ifadesinin ikizidir — ikisi ayrı dosyada olduğu için elle eşit
|
||||||
|
// tutulmalı. O dosya açıldığında (bkz. backlog: olayın gerçek çağrı
|
||||||
|
// yerine taşınması) tek yere alınacak. Ham sıralama/seçim GÖNDERİLMEZ
|
||||||
|
// (bkz. lib/analitik.ts kuralı) — yalnız profilin varlığı.
|
||||||
|
profil: (tercihProfiliOku() ?? sunucuProfil) ? "var" : "yok",
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
uretimKilidiAl,
|
||||||
|
uretimKilidiBirak,
|
||||||
|
} from "@/lib/uretim-kilidi";
|
||||||
import { getSession, getCurrentUser } from "@/lib/session";
|
import { getSession, getCurrentUser } from "@/lib/session";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
||||||
@@ -35,7 +39,14 @@ export type ListeSonuc =
|
|||||||
kredi: number;
|
kredi: number;
|
||||||
hasPaket: boolean;
|
hasPaket: boolean;
|
||||||
}
|
}
|
||||||
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" };
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
// SURUYOR: aynı kullanıcı için üretim hâlihazırda koşuyor (ikinci sekme,
|
||||||
|
// geri→ileri). Ayrı kod, çünkü istemci bunu "hata" sayıp taze bir
|
||||||
|
// requestId ile yeniden denerse çifte harcama kapısı yeniden açılır.
|
||||||
|
code: "AUTH" | "PAKET" | "KREDI" | "SURUYOR" | "HATA";
|
||||||
|
};
|
||||||
|
|
||||||
function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
|
function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
|
||||||
return (
|
return (
|
||||||
@@ -115,12 +126,35 @@ export async function listeOlustur(input: {
|
|||||||
|
|
||||||
const params = paramsBirlestir(input.sira, input.tur, secimler);
|
const params = paramsBirlestir(input.sira, input.tur, secimler);
|
||||||
|
|
||||||
|
// Kullanıcı başına üretim kilidi — kredi harcamasından ÖNCE. requestId
|
||||||
|
// idempotansı yalnız aynı denemenin tekrarını koruyor; ikinci sekmede açılan
|
||||||
|
// /listem?uret=1 taze bir requestId üretip 3 krediyi ikinci kez yakıyordu.
|
||||||
|
const kilit = await uretimKilidiAl(user.id, input.requestId);
|
||||||
|
if (!kilit.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "SURUYOR",
|
||||||
|
error: "Listen zaten hazırlanıyor. Bu sekmeyi kapatabilirsin.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await listeOlusturKilitli(user, params, input.requestId);
|
||||||
|
} finally {
|
||||||
|
await uretimKilidiBirak(user.id, input.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listeOlusturKilitli(
|
||||||
|
user: NonNullable<Awaited<ReturnType<typeof getCurrentUser>>>,
|
||||||
|
params: RaporParams,
|
||||||
|
requestId: string,
|
||||||
|
): Promise<ListeSonuc> {
|
||||||
// 3 kredi düş (idempotent: aynı requestId ile retry çifte harcamaz)
|
// 3 kredi düş (idempotent: aynı requestId ile retry çifte harcamaz)
|
||||||
const harcama = await spendCredits({
|
const harcama = await spendCredits({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
amount: RAPOR_KREDI,
|
amount: RAPOR_KREDI,
|
||||||
reason: "report_generate",
|
reason: "report_generate",
|
||||||
refId: input.requestId,
|
refId: requestId,
|
||||||
});
|
});
|
||||||
if (!harcama.ok) {
|
if (!harcama.ok) {
|
||||||
if (harcama.error === "INSUFFICIENT") {
|
if (harcama.error === "INSUFFICIENT") {
|
||||||
@@ -137,7 +171,7 @@ export async function listeOlustur(input: {
|
|||||||
const iade = await appDb.query.creditLedger.findFirst({
|
const iade = await appDb.query.creditLedger.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.creditLedger.reason, "refund"),
|
eq(schema.creditLedger.reason, "refund"),
|
||||||
eq(schema.creditLedger.refId, input.requestId),
|
eq(schema.creditLedger.refId, requestId),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (iade) {
|
if (iade) {
|
||||||
@@ -160,7 +194,14 @@ export async function listeOlustur(input: {
|
|||||||
hasPaket: user.hasPaket,
|
hasPaket: user.hasPaket,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
|
// Aynı requestId harcanmış, iade yok, rapor da henüz yazılmamış → o deneme
|
||||||
|
// hâlâ uçuşta. Genel HATA dönmek istemciyi taze bir requestId üretip
|
||||||
|
// yeniden denemeye itiyordu (BACKLOG #1'deki ikinci çifte harcama yolu).
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "SURUYOR",
|
||||||
|
error: "Listen zaten hazırlanıyor. Bu sekmeyi kapatabilirsin.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let sonuc: RaporSonuc;
|
let sonuc: RaporSonuc;
|
||||||
@@ -172,7 +213,7 @@ export async function listeOlustur(input: {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
delta: RAPOR_KREDI,
|
delta: RAPOR_KREDI,
|
||||||
reason: "refund",
|
reason: "refund",
|
||||||
refId: input.requestId,
|
refId: requestId,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
|
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function CtaSiraForm({
|
|||||||
const yeniProfil = { sira, tur, secimler };
|
const yeniProfil = { sira, tur, secimler };
|
||||||
tercihProfiliYaz(yeniProfil);
|
tercihProfiliYaz(yeniProfil);
|
||||||
setAcik(false);
|
setAcik(false);
|
||||||
router.push(sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" }));
|
router.push(sonucHref(girisli ? { sihirbaz: "1", kaynak: "cta" } : { hazir: "1" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function acikDegisti(v: boolean) {
|
function acikDegisti(v: boolean) {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function HeroForm() {
|
|||||||
const profil = { sira, tur, secimler };
|
const profil = { sira, tur, secimler };
|
||||||
tercihProfiliYaz(profil);
|
tercihProfiliYaz(profil);
|
||||||
setModalAcik(false);
|
setModalAcik(false);
|
||||||
router.push(sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" }));
|
router.push(sonucHref(girisli ? { sihirbaz: "1", kaynak: "hero" } : { hazir: "1" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
|
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ export async function SonucSihirbazi({
|
|||||||
sira,
|
sira,
|
||||||
tur,
|
tur,
|
||||||
otomatikAc,
|
otomatikAc,
|
||||||
|
kaynak,
|
||||||
secimlerHazir,
|
secimlerHazir,
|
||||||
}: {
|
}: {
|
||||||
sira: number;
|
sira: number;
|
||||||
tur: PuanTuruKey;
|
tur: PuanTuruKey;
|
||||||
otomatikAc: boolean;
|
otomatikAc: boolean;
|
||||||
|
/** ?sihirbaz=1'i üreten yüzey; otomatik üretimin ölçüm etiketi olur. */
|
||||||
|
kaynak?: string;
|
||||||
secimlerHazir: boolean;
|
secimlerHazir: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Kullanıcının kayıtlı raporu var mı? Yalnızca CTA'nın hangi hâlde render
|
// Kullanıcının kayıtlı raporu var mı? Yalnızca CTA'nın hangi hâlde render
|
||||||
@@ -35,6 +38,7 @@ export async function SonucSihirbazi({
|
|||||||
girisliMi={Boolean(session)}
|
girisliMi={Boolean(session)}
|
||||||
raporVar={Boolean(raporSatiri?.result)}
|
raporVar={Boolean(raporSatiri?.result)}
|
||||||
otomatikAc={otomatikAc}
|
otomatikAc={otomatikAc}
|
||||||
|
kaynak={kaynak}
|
||||||
secimlerHazir={secimlerHazir}
|
secimlerHazir={secimlerHazir}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,14 @@ import { SihirbazModal } from "./sihirbaz-modal";
|
|||||||
|
|
||||||
// Üretim /listem'de koşar (bkz. listem/liste-uretici.tsx); sihirbaz
|
// Üretim /listem'de koşar (bkz. listem/liste-uretici.tsx); sihirbaz
|
||||||
// tamamlanınca seçimler storage'a yazılıp kullanıcı buraya yönlendirilir.
|
// tamamlanınca seçimler storage'a yazılıp kullanıcı buraya yönlendirilir.
|
||||||
const LISTEM_URET_HREF = "/listem?uret=1";
|
// `kaynak` denemenin hangi yüzeyden başladığını ölçer (bkz.
|
||||||
|
// rapor/components/uretim-basladi-olayi.tsx — izinli değerler orada).
|
||||||
|
// `uret=1` HER ZAMAN ilk parametre kalmalı: giris-karti.tsx:31 funnel
|
||||||
|
// metnini `callbackURL.startsWith("/listem?uret=1")` ile seçiyor, sıra
|
||||||
|
// bozulursa girişteki "Seçimlerin hazır" metni kaybolur.
|
||||||
|
function listemUretHref(kaynak: string) {
|
||||||
|
return `/listem?uret=1&kaynak=${kaynak}`;
|
||||||
|
}
|
||||||
|
|
||||||
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
|
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
|
||||||
const bosAbone = () => () => {};
|
const bosAbone = () => () => {};
|
||||||
@@ -51,6 +58,7 @@ export function SihirbazBolumu({
|
|||||||
girisliMi,
|
girisliMi,
|
||||||
raporVar,
|
raporVar,
|
||||||
otomatikAc,
|
otomatikAc,
|
||||||
|
kaynak,
|
||||||
secimlerHazir,
|
secimlerHazir,
|
||||||
}: {
|
}: {
|
||||||
sira: number;
|
sira: number;
|
||||||
@@ -61,6 +69,7 @@ export function SihirbazBolumu({
|
|||||||
raporVar: boolean;
|
raporVar: boolean;
|
||||||
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
|
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
|
||||||
otomatikAc?: boolean;
|
otomatikAc?: boolean;
|
||||||
|
kaynak?: string;
|
||||||
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
|
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
|
||||||
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
|
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
|
||||||
render edilsin. */
|
render edilsin. */
|
||||||
@@ -182,10 +191,12 @@ export function SihirbazBolumu({
|
|||||||
await modalCikisiniOynat();
|
await modalCikisiniOynat();
|
||||||
setElleAcik(false);
|
setElleAcik(false);
|
||||||
if (!girisliMi) {
|
if (!girisliMi) {
|
||||||
router.push(`/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`);
|
router.push(
|
||||||
|
`/giris?callback=${encodeURIComponent(listemUretHref("sihirbaz"))}`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(LISTEM_URET_HREF);
|
router.push(listemUretHref("sihirbaz"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,7 +224,11 @@ export function SihirbazBolumu({
|
|||||||
if (otomatikGonder && !otomatikBasladi.current) {
|
if (otomatikGonder && !otomatikBasladi.current) {
|
||||||
otomatikBasladi.current = true;
|
otomatikBasladi.current = true;
|
||||||
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
|
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
|
||||||
router.replace(LISTEM_URET_HREF);
|
// Etiket ?sihirbaz=1'i ÜRETEN yüzeyden gelir. Eskiden burası sabit
|
||||||
|
// "giris_donusu" yazıyordu; oysa gerçek giriş dönüşü /listem'e
|
||||||
|
// doğrudan iner, /sonuc'a hiç uğramaz — hero, katalog, arama, + ve
|
||||||
|
// ödeme-sonrası tek yanlış kovada toplanıyordu.
|
||||||
|
router.replace(listemUretHref(kaynak ?? "bilinmiyor"));
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [otomatikGonder]);
|
}, [otomatikGonder]);
|
||||||
@@ -242,7 +257,7 @@ export function SihirbazBolumu({
|
|||||||
// kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar.
|
// kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar.
|
||||||
// Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin.
|
// Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin.
|
||||||
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar);
|
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar);
|
||||||
const girisUrl = `/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`;
|
const girisUrl = `/giris?callback=${encodeURIComponent(listemUretHref("hazir_cta"))}`;
|
||||||
// Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref
|
// Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref
|
||||||
// başka dalda bağlanmadığından observer da yalnızca o durumda kurulur.
|
// başka dalda bağlanmadığından observer da yalnızca o durumda kurulur.
|
||||||
const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi");
|
const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi");
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export type OlayAdi =
|
|||||||
| "sihirbaz_adim"
|
| "sihirbaz_adim"
|
||||||
| "sihirbaz_tamamlandi"
|
| "sihirbaz_tamamlandi"
|
||||||
| "giris_denendi"
|
| "giris_denendi"
|
||||||
|
// Üretim denemesinin paydası: /listem?uret=1 ile üretim gövdesi mount
|
||||||
|
// olduğunda atılır (sihirbaz, hazır panel CTA'sı, giriş dönüşü, boş durum
|
||||||
|
// CTA'sı ve ödeme sonrası kartı — hangisi olduğu `kaynak` özelliğinde).
|
||||||
|
// Bilerek `olayBirKez` KULLANILMAZ: aynı oturumdaki ikinci deneme de
|
||||||
|
// sayılmalı, yoksa payda başarıdan (liste_olusturuldu) küçük kalır.
|
||||||
|
| "liste_uretim_basladi"
|
||||||
| "liste_olusturuldu"
|
| "liste_olusturuldu"
|
||||||
| "liste_guncellendi"
|
| "liste_guncellendi"
|
||||||
| "liste_revize"
|
| "liste_revize"
|
||||||
|
|||||||
@@ -163,6 +163,24 @@ export const reports = sqliteTable("reports", {
|
|||||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Kullanıcı başına üretim kilidi. `credit_ledger.ref_id` benzersizliği yalnız
|
||||||
|
// AYNI denemenin tekrarını (ağda kaybolan cevap) koruyor; requestId istemcide
|
||||||
|
// her mount'ta yeniden üretildiği için "aynı kullanıcı üretimi iki kez
|
||||||
|
// başlattı" durumunu hiç yakalamıyordu: ikinci sekme ya da geri→ileri, ikinci
|
||||||
|
// bir ListeUretici monte edip 3 krediyi ikinci kez yakıyordu (tek rapor,
|
||||||
|
// çünkü reports.user_id benzersiz ve üstüne yazılıyor).
|
||||||
|
// userId birincil anahtar → ikinci INSERT benzersizlik ihlaliyle düşer, bu da
|
||||||
|
// "üretim sürüyor" demektir. startedAt bayat kilitleri (süreç öldü, konteyner
|
||||||
|
// yeniden başladı) devralmak için var.
|
||||||
|
export const reportLocks = sqliteTable("report_locks", {
|
||||||
|
userId: text("user_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => user.id),
|
||||||
|
/** Kilidi tutan deneme; salım yalnız sahibi tarafından yapılabilsin diye. */
|
||||||
|
requestId: text("request_id").notNull(),
|
||||||
|
startedAt: integer("started_at", { mode: "timestamp" }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
|
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
|
||||||
// Anahtar: sıra kovası × puan türü × kategori × üniversite tipi
|
// Anahtar: sıra kovası × puan türü × kategori × üniversite tipi
|
||||||
// ("genel" = kategorisiz/tipsiz fallback). Anonim istek LLM'e asla gitmez;
|
// ("genel" = kategorisiz/tipsiz fallback). Anonim istek LLM'e asla gitmez;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const MAX_REVIZYON = 2;
|
|||||||
* mesaj "Failed query: …" olur, "UNIQUE" yalnız `cause` zincirinde kalır. Yalnız
|
* mesaj "Failed query: …" olur, "UNIQUE" yalnız `cause` zincirinde kalır. Yalnız
|
||||||
* üst mesaja bakmak idempotent no-op yerine hatayı yukarı fırlatıyordu.
|
* üst mesaja bakmak idempotent no-op yerine hatayı yukarı fırlatıyordu.
|
||||||
*/
|
*/
|
||||||
function uniqueIhlaliMi(err: unknown): boolean {
|
export function uniqueIhlaliMi(err: unknown): boolean {
|
||||||
for (let e: unknown = err, i = 0; e != null && i < 5; i++) {
|
for (let e: unknown = err, i = 0; e != null && i < 5; i++) {
|
||||||
const h = e as {
|
const h = e as {
|
||||||
message?: unknown;
|
message?: unknown;
|
||||||
|
|||||||
86
src/lib/sezon.ts
Normal file
86
src/lib/sezon.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Tercih sezonu takvimi — TEK KAYNAK. 2027 takvimi açıklanınca buraya yazılır;
|
||||||
|
* başka hiçbir yer değişmez. Saf: yalnız `veri-yillari` sabitlerini okur, yan
|
||||||
|
* etkisi yok — client, server ve betikler aynı dosyayı kullanabilir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BASLIK_YILI, TERCIH_YILI } from "@/lib/veri-yillari";
|
||||||
|
|
||||||
|
/** Bir sezonun ÖSYM takvimi. ISO 8601, Türkiye saati (+03:00) ofsetiyle. */
|
||||||
|
export type SezonAraligi = {
|
||||||
|
readonly baslangic: string;
|
||||||
|
readonly bitis: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yıl → tercih dönemi aralığı. `null` = takvim henüz açıklanmadı; tarih
|
||||||
|
* UYDURULMAZ, sezon "takvim-belirsiz" davranışına düşer.
|
||||||
|
*/
|
||||||
|
export const SEZON_TAKVIMI: Readonly<Record<number, SezonAraligi | null>> = {
|
||||||
|
2026: {
|
||||||
|
baslangic: "2026-07-29T00:00:00+03:00",
|
||||||
|
bitis: "2026-08-10T23:59:59+03:00",
|
||||||
|
},
|
||||||
|
// ÖSYM 2027 tercih takvimini açıklamadı (22 Eyl 2026 itibarıyla).
|
||||||
|
2027: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SezonAsamasi =
|
||||||
|
| "takvim-belirsiz"
|
||||||
|
| "baslamadi"
|
||||||
|
| "devam-ediyor"
|
||||||
|
| "sona-erdi";
|
||||||
|
|
||||||
|
/** Verilen anda sezonun hangi aşamada olduğu. */
|
||||||
|
export function sezonAsamasi(
|
||||||
|
simdi: number,
|
||||||
|
yil: number = TERCIH_YILI,
|
||||||
|
): SezonAsamasi {
|
||||||
|
const takvim = SEZON_TAKVIMI[yil] ?? null;
|
||||||
|
|
||||||
|
if (!takvim) {
|
||||||
|
return "takvim-belirsiz";
|
||||||
|
}
|
||||||
|
|
||||||
|
const baslangic = new Date(takvim.baslangic).getTime();
|
||||||
|
const bitis = new Date(takvim.bitis).getTime();
|
||||||
|
|
||||||
|
return simdi < baslangic
|
||||||
|
? "baslamadi"
|
||||||
|
: simdi <= bitis
|
||||||
|
? "devam-ediyor"
|
||||||
|
: "sona-erdi";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geri sayımın hedef zaman damgası: dönem başlamadıysa başlangıç, sürüyorsa
|
||||||
|
* bitiş. `null` dönen aşamalarda (takvim belirsiz ya da dönem kapandı) sayılacak
|
||||||
|
* bir şey yoktur — çağıran geri sayımı hiç render etmemelidir.
|
||||||
|
*/
|
||||||
|
export function sezonHedefi(
|
||||||
|
asama: SezonAsamasi,
|
||||||
|
yil: number = TERCIH_YILI,
|
||||||
|
): number | null {
|
||||||
|
const takvim = SEZON_TAKVIMI[yil] ?? null;
|
||||||
|
|
||||||
|
if (!takvim) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asama === "baslamadi") {
|
||||||
|
return new Date(takvim.baslangic).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
return asama === "devam-ediyor" ? new Date(takvim.bitis).getTime() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Üst bandın saat ikonunun yanındaki metin — aşama başına tek kaynak. */
|
||||||
|
export const BANT_METNI: Record<SezonAsamasi, string> = {
|
||||||
|
"takvim-belirsiz": `${BASLIK_YILI} yerleştirme verileri yüklendi — ${TERCIH_YILI} tercih takvimi açıklanınca geri sayım burada başlar`,
|
||||||
|
baslamadi: "Başlamasına",
|
||||||
|
"devam-ediyor": "Sona ermesine",
|
||||||
|
"sona-erdi": `${TERCIH_YILI} tercihleri tamamlandı`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Üst bandın `aria-label`'ı. */
|
||||||
|
export const BANT_ARIA = `${TERCIH_YILI} YKS tercih dönemi`;
|
||||||
77
src/lib/uretim-kilidi.ts
Normal file
77
src/lib/uretim-kilidi.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { and, eq, lt } from "drizzle-orm";
|
||||||
|
import { appDb, schema } from "./appdb";
|
||||||
|
import { uniqueIhlaliMi } from "./credits";
|
||||||
|
|
||||||
|
const { reportLocks } = schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bayat kilit eşiği. Tek Yapay Zeka çağrısının sunucu sınırı 60 sn ve üretim
|
||||||
|
* iki denemeye kadar gidebiliyor (bkz. lib/ai/cagri.ts), üstüne havuz SQL'i ve
|
||||||
|
* DB yazımı biniyor. 180 sn bu üst sınırın rahatça üstünde: bu kadar eski bir
|
||||||
|
* kilit ancak süreç öldüyse (OOM, deploy) kalmıştır ve devralınabilir.
|
||||||
|
*/
|
||||||
|
const BAYAT_MS = 180_000;
|
||||||
|
|
||||||
|
export type KilitSonuc =
|
||||||
|
| { ok: true }
|
||||||
|
/** Aynı kullanıcı için taze bir üretim zaten koşuyor. */
|
||||||
|
| { ok: false; error: "SURUYOR" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kullanıcı başına üretim kilidini alır.
|
||||||
|
*
|
||||||
|
* Neden var: `credit_ledger.ref_id` benzersizliği yalnız AYNI denemenin
|
||||||
|
* tekrarını koruyor (ağda kaybolan cevap → aynı requestId ile retry). Oysa
|
||||||
|
* `requestId` istemcide her mount'ta yeniden üretiliyor, yani ikinci sekmede
|
||||||
|
* açılan `/listem?uret=1` ya da geri→ileri ikinci bir üretim başlatıp 3 krediyi
|
||||||
|
* ikinci kez yakıyordu — tek rapor karşılığında (reports.user_id benzersiz).
|
||||||
|
*
|
||||||
|
* Kilit krediden ÖNCE alınır: harcama yapılmadan reddedilir.
|
||||||
|
*/
|
||||||
|
export async function uretimKilidiAl(
|
||||||
|
userId: string,
|
||||||
|
requestId: string,
|
||||||
|
): Promise<KilitSonuc> {
|
||||||
|
const simdi = new Date();
|
||||||
|
try {
|
||||||
|
await appDb
|
||||||
|
.insert(reportLocks)
|
||||||
|
.values({ userId, requestId, startedAt: simdi });
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
if (!uniqueIhlaliMi(err)) throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kilit var. Yalnız bayatsa devral — koşullu UPDATE olduğu için iki eşzamanlı
|
||||||
|
// devralma denemesinden yalnız biri satırı günceller (rowsAffected ile ayırt
|
||||||
|
// edilir), yani yarış çifte harcamaya dönüşemez.
|
||||||
|
const devir = await appDb
|
||||||
|
.update(reportLocks)
|
||||||
|
.set({ requestId, startedAt: simdi })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reportLocks.userId, userId),
|
||||||
|
lt(reportLocks.startedAt, new Date(simdi.getTime() - BAYAT_MS)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (devir.rowsAffected > 0) return { ok: true };
|
||||||
|
return { ok: false, error: "SURUYOR" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kilidi bırakır. `requestId` koşulu şart: bayat kilidi devralan başka bir
|
||||||
|
* deneme varken, geç uyanan eski sahip onun kilidini silmemeli.
|
||||||
|
*/
|
||||||
|
export async function uretimKilidiBirak(
|
||||||
|
userId: string,
|
||||||
|
requestId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await appDb
|
||||||
|
.delete(reportLocks)
|
||||||
|
.where(
|
||||||
|
and(eq(reportLocks.userId, userId), eq(reportLocks.requestId, requestId)),
|
||||||
|
)
|
||||||
|
// Salım hiçbir zaman asıl akışı kırmamalı: kilit en kötü ihtimalle
|
||||||
|
// BAYAT_MS sonra kendiliğinden devralınabilir hâle gelir.
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user